This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
142
src/discord/monitor/auto-presence.test.ts
Normal file
142
src/discord/monitor/auto-presence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
358
src/discord/monitor/auto-presence.ts
Normal file
358
src/discord/monitor/auto-presence.ts
Normal 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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user