diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8580c33326b..23a0ac7f3fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index 15a92fc5161..22bef8d5fe2 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -786,7 +786,7 @@ Default slash command settings:
- 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)
+
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index ceba7b19d95..1564248de18 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -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..users` on all messages).
diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts
index 92c22ac14b2..e78a36db28c 100644
--- a/src/agents/auth-profiles/usage.ts
+++ b/src/agents/auth-profiles/usage.ts
@@ -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 {
diff --git a/src/config/config.discord-presence.test.ts b/src/config/config.discord-presence.test.ts
index 4ecacfab190..f31285a678d 100644
--- a/src/config/config.discord-presence.test.ts
+++ b/src/config/config.discord-presence.test.ts
@@ -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);
+ });
});
diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts
index f4f0023f7fd..3b3f5cecbc4 100644
--- a/src/config/schema.help.ts
+++ b/src/config/schema.help.ts
@@ -1455,6 +1455,18 @@ export const FIELD_HELP: Record = {
"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).",
diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts
index ee1b09e322c..cb7df9ce718 100644
--- a/src/config/schema.labels.ts
+++ b/src/config/schema.labels.ts
@@ -725,6 +725,13 @@ export const FIELD_LABELS: Record = {
"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",
diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts
index 2c0227ee863..cda5d6c6a75 100644
--- a/src/config/types.discord.ts
+++ b/src/config/types.discord.ts
@@ -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. */
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 4ba06e4bd8e..4b3426c6f8a 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -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.
});
diff --git a/src/discord/monitor/auto-presence.test.ts b/src/discord/monitor/auto-presence.test.ts
new file mode 100644
index 00000000000..0065ed77be7
--- /dev/null
+++ b/src/discord/monitor/auto-presence.test.ts
@@ -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;
+}): 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();
+ });
+});
diff --git a/src/discord/monitor/auto-presence.ts b/src/discord/monitor/auto-presence.ts
new file mode 100644
index 00000000000..74bdcab3617
--- /dev/null
+++ b/src/discord/monitor/auto-presence.ts
@@ -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 | 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 | 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,
+};
diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts
index 8e597e8dca6..74a0ad51d3f 100644
--- a/src/discord/monitor/provider.test.ts
+++ b/src/discord/monitor/provider.test.ts
@@ -7,6 +7,7 @@ const {
clientFetchUserMock,
clientGetPluginMock,
clientConstructorOptionsMock,
+ createDiscordAutoPresenceControllerMock,
createDiscordNativeCommandMock,
createNoopThreadBindingManagerMock,
createThreadBindingManagerMock,
@@ -23,6 +24,13 @@ const {
const createdBindingManagers: Array<{ stop: ReturnType }> = [];
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();
+ });
});
diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts
index 715d7383304..b9a5599c853 100644
--- a/src/discord/monitor/provider.ts
+++ b/src/discord/monitor/provider.ts
@@ -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 | 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("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("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();