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

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