fix: discord auto presence health signal (#33277) (thanks @thewilloftheshadow) (#33277)

This commit is contained in:
Shadow
2026-03-03 11:20:59 -06:00
committed by GitHub
parent 3d998828b9
commit e28ff1215c
13 changed files with 690 additions and 3 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
- Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow.
- Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
- Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress `NO_REPLY` lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.

View File

@@ -786,7 +786,7 @@ Default slash command settings:
</Accordion>
<Accordion title="Presence configuration">
Presence updates are applied only when you set a status or activity field.
Presence updates are applied when you set a status or activity field, or when you enable auto presence.
Status only example:
@@ -836,6 +836,29 @@ Default slash command settings:
- 4: Custom (uses the activity text as the status state; emoji is optional)
- 5: Competing
Auto presence example (runtime health signal):
```json5
{
channels: {
discord: {
autoPresence: {
enabled: true,
intervalMs: 30000,
minUpdateIntervalMs: 15000,
exhaustedText: "token exhausted",
},
},
},
}
```
Auto presence maps runtime availability to Discord status: healthy => online, degraded or unknown => idle, exhausted or unavailable => dnd. Optional text overrides:
- `autoPresence.healthyText`
- `autoPresence.degradedText`
- `autoPresence.exhaustedText` (supports `{reason}` placeholder)
</Accordion>
<Accordion title="Exec approvals in Discord">

View File

@@ -317,6 +317,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default).
- 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. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).

View File

@@ -37,7 +37,11 @@ export function resolveProfileUnusableUntil(
/**
* Check if a profile is currently in cooldown (due to rate limiting or errors).
*/
export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean {
export function isProfileInCooldown(
store: AuthProfileStore,
profileId: string,
now?: number,
): boolean {
if (isAuthCooldownBypassedForProvider(store.profiles[profileId]?.provider)) {
return false;
}
@@ -46,7 +50,8 @@ export function isProfileInCooldown(store: AuthProfileStore, profileId: string):
return false;
}
const unusableUntil = resolveProfileUnusableUntil(stats);
return unusableUntil ? Date.now() < unusableUntil : false;
const ts = now ?? Date.now();
return unusableUntil ? ts < unusableUntil : false;
}
function isActiveUnusableWindow(until: number | undefined, now: number): boolean {

View File

@@ -64,4 +64,37 @@ describe("config discord presence", () => {
expect(res.ok).toBe(false);
});
it("accepts auto presence config", () => {
const res = validateConfigObject({
channels: {
discord: {
autoPresence: {
enabled: true,
intervalMs: 30000,
minUpdateIntervalMs: 15000,
exhaustedText: "token exhausted",
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects auto presence min update interval above check interval", () => {
const res = validateConfigObject({
channels: {
discord: {
autoPresence: {
enabled: true,
intervalMs: 5000,
minUpdateIntervalMs: 6000,
},
},
},
});
expect(res.ok).toBe(false);
});
});

View File

@@ -1455,6 +1455,18 @@ export const FIELD_HELP: Record<string, string> = {
"Optional PluralKit token for resolving private systems or members.",
"channels.discord.activity": "Discord presence activity text (defaults to custom status).",
"channels.discord.status": "Discord presence status (online, dnd, idle, invisible).",
"channels.discord.autoPresence.enabled":
"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd.",
"channels.discord.autoPresence.intervalMs":
"How often to evaluate Discord auto-presence state in milliseconds (default: 30000).",
"channels.discord.autoPresence.minUpdateIntervalMs":
"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes.",
"channels.discord.autoPresence.healthyText":
"Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set.",
"channels.discord.autoPresence.degradedText":
"Optional custom status text while runtime/model availability is degraded or unknown (idle).",
"channels.discord.autoPresence.exhaustedText":
"Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder.",
"channels.discord.activityType":
"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).",
"channels.discord.activityUrl": "Discord presence streaming URL (required for activityType=1).",

View File

@@ -725,6 +725,13 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.discord.pluralkit.token": "Discord PluralKit Token",
"channels.discord.activity": "Discord Presence Activity",
"channels.discord.status": "Discord Presence Status",
"channels.discord.autoPresence.enabled": "Discord Auto Presence Enabled",
"channels.discord.autoPresence.intervalMs": "Discord Auto Presence Check Interval (ms)",
"channels.discord.autoPresence.minUpdateIntervalMs":
"Discord Auto Presence Min Update Interval (ms)",
"channels.discord.autoPresence.healthyText": "Discord Auto Presence Healthy Text",
"channels.discord.autoPresence.degradedText": "Discord Auto Presence Degraded Text",
"channels.discord.autoPresence.exhaustedText": "Discord Auto Presence Exhausted Text",
"channels.discord.activityType": "Discord Presence Activity Type",
"channels.discord.activityUrl": "Discord Presence Activity URL",
"channels.slack.dm.policy": "Slack DM Policy",

View File

@@ -190,6 +190,21 @@ export type DiscordSlashCommandConfig = {
ephemeral?: boolean;
};
export type DiscordAutoPresenceConfig = {
/** Enable automatic runtime/quota-based Discord presence updates. Default: false. */
enabled?: boolean;
/** Poll interval for evaluating runtime availability state (ms). Default: 30000. */
intervalMs?: number;
/** Minimum spacing between actual gateway presence updates (ms). Default: 15000. */
minUpdateIntervalMs?: number;
/** Optional custom status text while runtime is healthy; supports plain text. */
healthyText?: string;
/** Optional custom status text while runtime/quota state is degraded or unknown. */
degradedText?: string;
/** Optional custom status text while runtime detects quota/token exhaustion. */
exhaustedText?: string;
};
export type DiscordAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -308,6 +323,8 @@ export type DiscordAccountConfig = {
activity?: string;
/** Bot status (online|dnd|idle|invisible). Defaults to online when presence is configured. */
status?: "online" | "dnd" | "idle" | "invisible";
/** Automatic runtime/quota presence signaling (status text + status mapping). */
autoPresence?: DiscordAutoPresenceConfig;
/** Activity type (0=Game, 1=Streaming, 2=Listening, 3=Watching, 4=Custom, 5=Competing). Defaults to 4 (Custom) when activity is set. */
activityType?: 0 | 1 | 2 | 3 | 4 | 5;
/** Streaming URL (Twitch/YouTube). Required when activityType=1. */

View File

@@ -512,6 +512,17 @@ export const DiscordAccountSchema = z
.optional(),
activity: z.string().optional(),
status: z.enum(["online", "dnd", "idle", "invisible"]).optional(),
autoPresence: z
.object({
enabled: z.boolean().optional(),
intervalMs: z.number().int().positive().optional(),
minUpdateIntervalMs: z.number().int().positive().optional(),
healthyText: z.string().optional(),
degradedText: z.string().optional(),
exhaustedText: z.string().optional(),
})
.strict()
.optional(),
activityType: z
.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)])
.optional(),
@@ -559,6 +570,21 @@ export const DiscordAccountSchema = z
});
}
const autoPresenceInterval = value.autoPresence?.intervalMs;
const autoPresenceMinUpdate = value.autoPresence?.minUpdateIntervalMs;
if (
typeof autoPresenceInterval === "number" &&
typeof autoPresenceMinUpdate === "number" &&
autoPresenceMinUpdate > autoPresenceInterval
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"channels.discord.autoPresence.minUpdateIntervalMs must be less than or equal to channels.discord.autoPresence.intervalMs",
path: ["autoPresence", "minUpdateIntervalMs"],
});
}
// DM allowlist validation is enforced at DiscordConfigSchema so account entries
// can inherit top-level allowFrom via runtime shallow merge.
});

View File

@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import {
createDiscordAutoPresenceController,
resolveDiscordAutoPresenceDecision,
} from "./auto-presence.js";
function createStore(params?: {
cooldownUntil?: number;
failureCounts?: Record<string, number>;
}): AuthProfileStore {
return {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
},
usageStats: {
"openai:default": {
...(typeof params?.cooldownUntil === "number"
? { cooldownUntil: params.cooldownUntil }
: {}),
...(params?.failureCounts ? { failureCounts: params.failureCounts } : {}),
},
},
};
}
describe("discord auto presence", () => {
it("maps exhausted runtime signal to dnd", () => {
const now = Date.now();
const decision = resolveDiscordAutoPresenceDecision({
discordConfig: {
autoPresence: {
enabled: true,
exhaustedText: "token exhausted",
},
},
authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { rate_limit: 2 } }),
gatewayConnected: true,
now,
});
expect(decision).toBeTruthy();
expect(decision?.state).toBe("exhausted");
expect(decision?.presence.status).toBe("dnd");
expect(decision?.presence.activities[0]?.state).toBe("token exhausted");
});
it("recovers from exhausted to online once a profile becomes usable", () => {
let now = Date.now();
let store = createStore({ cooldownUntil: now + 60_000, failureCounts: { rate_limit: 1 } });
const updatePresence = vi.fn();
const controller = createDiscordAutoPresenceController({
accountId: "default",
discordConfig: {
autoPresence: {
enabled: true,
intervalMs: 5_000,
minUpdateIntervalMs: 1_000,
exhaustedText: "token exhausted",
},
},
gateway: {
isConnected: true,
updatePresence,
},
loadAuthStore: () => store,
now: () => now,
});
controller.runNow();
now += 2_000;
store = createStore();
controller.runNow();
expect(updatePresence).toHaveBeenCalledTimes(2);
expect(updatePresence.mock.calls[0]?.[0]?.status).toBe("dnd");
expect(updatePresence.mock.calls[1]?.[0]?.status).toBe("online");
});
it("re-applies presence on refresh even when signature is unchanged", () => {
let now = Date.now();
const store = createStore();
const updatePresence = vi.fn();
const controller = createDiscordAutoPresenceController({
accountId: "default",
discordConfig: {
autoPresence: {
enabled: true,
intervalMs: 60_000,
minUpdateIntervalMs: 60_000,
},
},
gateway: {
isConnected: true,
updatePresence,
},
loadAuthStore: () => store,
now: () => now,
});
controller.runNow();
now += 1_000;
controller.runNow();
controller.refresh();
expect(updatePresence).toHaveBeenCalledTimes(2);
expect(updatePresence.mock.calls[0]?.[0]?.status).toBe("online");
expect(updatePresence.mock.calls[1]?.[0]?.status).toBe("online");
});
it("does nothing when auto presence is disabled", () => {
const updatePresence = vi.fn();
const controller = createDiscordAutoPresenceController({
accountId: "default",
discordConfig: {
autoPresence: {
enabled: false,
},
},
gateway: {
isConnected: true,
updatePresence,
},
loadAuthStore: () => createStore(),
});
controller.runNow();
controller.start();
controller.refresh();
controller.stop();
expect(controller.enabled).toBe(false);
expect(updatePresence).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,358 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import {
clearExpiredCooldowns,
ensureAuthProfileStore,
isProfileInCooldown,
resolveProfilesUnavailableReason,
type AuthProfileFailureReason,
type AuthProfileStore,
} from "../../agents/auth-profiles.js";
import type { DiscordAccountConfig, DiscordAutoPresenceConfig } from "../../config/config.js";
import { warn } from "../../globals.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4;
const CUSTOM_STATUS_NAME = "Custom Status";
const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_MIN_UPDATE_INTERVAL_MS = 15_000;
const MIN_INTERVAL_MS = 5_000;
const MIN_UPDATE_INTERVAL_MS = 1_000;
export type DiscordAutoPresenceState = "healthy" | "degraded" | "exhausted";
type ResolvedDiscordAutoPresenceConfig = {
enabled: boolean;
intervalMs: number;
minUpdateIntervalMs: number;
healthyText?: string;
degradedText?: string;
exhaustedText?: string;
};
export type DiscordAutoPresenceDecision = {
state: DiscordAutoPresenceState;
unavailableReason?: AuthProfileFailureReason | null;
presence: UpdatePresenceData;
};
type PresenceGateway = {
isConnected: boolean;
updatePresence: (payload: UpdatePresenceData) => void;
};
function normalizeOptionalText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function clampPositiveInt(value: unknown, fallback: number, minValue: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.round(value);
if (rounded <= 0) {
return fallback;
}
return Math.max(minValue, rounded);
}
function resolveAutoPresenceConfig(
config?: DiscordAutoPresenceConfig,
): ResolvedDiscordAutoPresenceConfig {
const intervalMs = clampPositiveInt(config?.intervalMs, DEFAULT_INTERVAL_MS, MIN_INTERVAL_MS);
const minUpdateIntervalMs = clampPositiveInt(
config?.minUpdateIntervalMs,
DEFAULT_MIN_UPDATE_INTERVAL_MS,
MIN_UPDATE_INTERVAL_MS,
);
return {
enabled: config?.enabled === true,
intervalMs,
minUpdateIntervalMs,
healthyText: normalizeOptionalText(config?.healthyText),
degradedText: normalizeOptionalText(config?.degradedText),
exhaustedText: normalizeOptionalText(config?.exhaustedText),
};
}
function buildCustomStatusActivity(text: string): Activity {
return {
name: CUSTOM_STATUS_NAME,
type: DEFAULT_CUSTOM_ACTIVITY_TYPE,
state: text,
};
}
function renderTemplate(
template: string,
vars: Record<string, string | undefined>,
): string | undefined {
const rendered = template
.replace(/\{([a-zA-Z0-9_]+)\}/g, (_full, key: string) => vars[key] ?? "")
.replace(/\s+/g, " ")
.trim();
return rendered.length > 0 ? rendered : undefined;
}
function isExhaustedUnavailableReason(reason: AuthProfileFailureReason | null): boolean {
if (!reason) {
return false;
}
return (
reason === "rate_limit" ||
reason === "billing" ||
reason === "auth" ||
reason === "auth_permanent"
);
}
function formatUnavailableReason(reason: AuthProfileFailureReason | null): string {
if (!reason) {
return "unknown";
}
return reason.replace(/_/g, " ");
}
function resolveAuthAvailability(params: { store: AuthProfileStore; now: number }): {
state: DiscordAutoPresenceState;
unavailableReason?: AuthProfileFailureReason | null;
} {
const profileIds = Object.keys(params.store.profiles);
if (profileIds.length === 0) {
return { state: "degraded", unavailableReason: null };
}
clearExpiredCooldowns(params.store, params.now);
const hasUsableProfile = profileIds.some(
(profileId) => !isProfileInCooldown(params.store, profileId, params.now),
);
if (hasUsableProfile) {
return { state: "healthy", unavailableReason: null };
}
const unavailableReason = resolveProfilesUnavailableReason({
store: params.store,
profileIds,
now: params.now,
});
if (isExhaustedUnavailableReason(unavailableReason)) {
return {
state: "exhausted",
unavailableReason,
};
}
return {
state: "degraded",
unavailableReason,
};
}
function resolvePresenceActivities(params: {
state: DiscordAutoPresenceState;
cfg: ResolvedDiscordAutoPresenceConfig;
basePresence: UpdatePresenceData | null;
unavailableReason?: AuthProfileFailureReason | null;
}): Activity[] {
const reasonLabel = formatUnavailableReason(params.unavailableReason ?? null);
if (params.state === "healthy") {
if (params.cfg.healthyText) {
return [buildCustomStatusActivity(params.cfg.healthyText)];
}
return params.basePresence?.activities ?? [];
}
if (params.state === "degraded") {
const template = params.cfg.degradedText ?? "runtime degraded";
const text = renderTemplate(template, { reason: reasonLabel });
return text ? [buildCustomStatusActivity(text)] : [];
}
const defaultTemplate = isExhaustedUnavailableReason(params.unavailableReason ?? null)
? "token exhausted"
: "model unavailable ({reason})";
const template = params.cfg.exhaustedText ?? defaultTemplate;
const text = renderTemplate(template, { reason: reasonLabel });
return text ? [buildCustomStatusActivity(text)] : [];
}
function resolvePresenceStatus(state: DiscordAutoPresenceState): UpdatePresenceData["status"] {
if (state === "healthy") {
return "online";
}
if (state === "exhausted") {
return "dnd";
}
return "idle";
}
export function resolveDiscordAutoPresenceDecision(params: {
discordConfig: Pick<
DiscordAccountConfig,
"autoPresence" | "activity" | "status" | "activityType" | "activityUrl"
>;
authStore: AuthProfileStore;
gatewayConnected: boolean;
now?: number;
}): DiscordAutoPresenceDecision | null {
const autoPresence = resolveAutoPresenceConfig(params.discordConfig.autoPresence);
if (!autoPresence.enabled) {
return null;
}
const now = params.now ?? Date.now();
const basePresence = resolveDiscordPresenceUpdate(params.discordConfig);
const availability = resolveAuthAvailability({
store: params.authStore,
now,
});
const state = params.gatewayConnected ? availability.state : "degraded";
const unavailableReason = params.gatewayConnected
? availability.unavailableReason
: (availability.unavailableReason ?? "unknown");
const activities = resolvePresenceActivities({
state,
cfg: autoPresence,
basePresence,
unavailableReason,
});
return {
state,
unavailableReason,
presence: {
since: null,
activities,
status: resolvePresenceStatus(state),
afk: false,
},
};
}
function stablePresenceSignature(payload: UpdatePresenceData): string {
return JSON.stringify({
status: payload.status,
afk: payload.afk,
since: payload.since,
activities: payload.activities.map((activity) => ({
type: activity.type,
name: activity.name,
state: activity.state,
url: activity.url,
})),
});
}
export type DiscordAutoPresenceController = {
start: () => void;
stop: () => void;
refresh: () => void;
runNow: () => void;
enabled: boolean;
};
export function createDiscordAutoPresenceController(params: {
accountId: string;
discordConfig: Pick<
DiscordAccountConfig,
"autoPresence" | "activity" | "status" | "activityType" | "activityUrl"
>;
gateway: PresenceGateway;
loadAuthStore?: () => AuthProfileStore;
now?: () => number;
setIntervalFn?: typeof setInterval;
clearIntervalFn?: typeof clearInterval;
log?: (message: string) => void;
}): DiscordAutoPresenceController {
const autoCfg = resolveAutoPresenceConfig(params.discordConfig.autoPresence);
if (!autoCfg.enabled) {
return {
enabled: false,
start: () => undefined,
stop: () => undefined,
refresh: () => undefined,
runNow: () => undefined,
};
}
const loadAuthStore = params.loadAuthStore ?? (() => ensureAuthProfileStore());
const now = params.now ?? (() => Date.now());
const setIntervalFn = params.setIntervalFn ?? setInterval;
const clearIntervalFn = params.clearIntervalFn ?? clearInterval;
let timer: ReturnType<typeof setInterval> | undefined;
let lastAppliedSignature: string | null = null;
let lastAppliedAt = 0;
const runEvaluation = (options?: { force?: boolean }) => {
let decision: DiscordAutoPresenceDecision | null = null;
try {
decision = resolveDiscordAutoPresenceDecision({
discordConfig: params.discordConfig,
authStore: loadAuthStore(),
gatewayConnected: params.gateway.isConnected,
now: now(),
});
} catch (err) {
params.log?.(
warn(
`discord: auto-presence evaluation failed for account ${params.accountId}: ${String(err)}`,
),
);
return;
}
if (!decision || !params.gateway.isConnected) {
return;
}
const forceApply = options?.force === true;
const ts = now();
const signature = stablePresenceSignature(decision.presence);
if (!forceApply && signature === lastAppliedSignature) {
return;
}
if (!forceApply && lastAppliedAt > 0 && ts - lastAppliedAt < autoCfg.minUpdateIntervalMs) {
return;
}
params.gateway.updatePresence(decision.presence);
lastAppliedSignature = signature;
lastAppliedAt = ts;
};
return {
enabled: true,
runNow: () => runEvaluation(),
refresh: () => runEvaluation({ force: true }),
start: () => {
if (timer) {
return;
}
runEvaluation({ force: true });
timer = setIntervalFn(() => runEvaluation(), autoCfg.intervalMs);
},
stop: () => {
if (!timer) {
return;
}
clearIntervalFn(timer);
timer = undefined;
},
};
}
export const __testing = {
resolveAutoPresenceConfig,
resolveAuthAvailability,
stablePresenceSignature,
};

View File

@@ -7,6 +7,7 @@ const {
clientFetchUserMock,
clientGetPluginMock,
clientConstructorOptionsMock,
createDiscordAutoPresenceControllerMock,
createDiscordNativeCommandMock,
createNoopThreadBindingManagerMock,
createThreadBindingManagerMock,
@@ -23,6 +24,13 @@ const {
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
return {
clientConstructorOptionsMock: vi.fn(),
createDiscordAutoPresenceControllerMock: vi.fn(() => ({
enabled: false,
start: vi.fn(),
stop: vi.fn(),
refresh: vi.fn(),
runNow: vi.fn(),
})),
clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })),
clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined),
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
@@ -220,6 +228,10 @@ vi.mock("./presence.js", () => ({
resolveDiscordPresenceUpdate: () => undefined,
}));
vi.mock("./auto-presence.js", () => ({
createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock,
}));
vi.mock("./provider.allowlist.js", () => ({
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
}));
@@ -268,6 +280,13 @@ describe("monitorDiscordProvider", () => {
beforeEach(() => {
clientConstructorOptionsMock.mockClear();
createDiscordAutoPresenceControllerMock.mockClear().mockImplementation(() => ({
enabled: false,
start: vi.fn(),
stop: vi.fn(),
refresh: vi.fn(),
runNow: vi.fn(),
}));
clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" });
clientGetPluginMock.mockClear().mockReturnValue(undefined);
createDiscordNativeCommandMock.mockClear().mockReturnValue({ name: "mock-command" });
@@ -385,4 +404,24 @@ describe("monitorDiscordProvider", () => {
const eventQueue = getConstructedEventQueue();
expect(eventQueue?.listenerTimeout).toBe(300_000);
});
it("reports connected status on startup and shutdown", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
const setStatus = vi.fn();
clientGetPluginMock.mockImplementation((name: string) =>
name === "gateway" ? { isConnected: true } : undefined,
);
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
setStatus,
});
const connectedTrue = setStatus.mock.calls.find((call) => call[0]?.connected === true);
const connectedFalse = setStatus.mock.calls.find((call) => call[0]?.connected === false);
expect(connectedTrue).toBeDefined();
expect(connectedFalse).toBeDefined();
});
});

View File

@@ -54,6 +54,7 @@ import {
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
} from "./agent-components.js";
import { createDiscordAutoPresenceController } from "./auto-presence.js";
import { resolveDiscordSlashCommandConfig } from "./commands.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
@@ -356,6 +357,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
let lifecycleStarted = false;
let releaseEarlyGatewayErrorGuard = () => {};
let autoPresenceController: ReturnType<typeof createDiscordAutoPresenceController> | null = null;
try {
const commands: BaseCommand[] = commandSpecs.map((spec) =>
createDiscordNativeCommand({
@@ -450,6 +452,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
class DiscordStatusReadyListener extends ReadyListener {
async handle(_data: unknown, client: Client) {
if (autoPresenceController?.enabled) {
autoPresenceController.refresh();
return;
}
const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (!gateway) {
return;
@@ -497,6 +504,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const earlyGatewayErrorGuard = attachEarlyGatewayErrorGuard(client);
releaseEarlyGatewayErrorGuard = earlyGatewayErrorGuard.release;
const lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
if (lifecycleGateway) {
autoPresenceController = createDiscordAutoPresenceController({
accountId: account.accountId,
discordConfig: discordCfg,
gateway: lifecycleGateway,
log: (message) => runtime.log?.(message),
});
autoPresenceController.start();
}
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
const logger = createSubsystemLogger("discord/monitor");
@@ -598,6 +616,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const botIdentity =
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`);
if (lifecycleGateway?.isConnected) {
opts.setStatus?.({ connected: true });
}
lifecycleStarted = true;
await runDiscordGatewayLifecycle({
@@ -615,6 +636,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
releaseEarlyGatewayErrorGuard,
});
} finally {
autoPresenceController?.stop();
opts.setStatus?.({ connected: false });
releaseEarlyGatewayErrorGuard();
if (!lifecycleStarted) {
threadBindings.stop();