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();