mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:38:38 +00:00
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/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/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/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/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.
|
- 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.
|
- 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>
|
||||||
|
|
||||||
<Accordion title="Presence configuration">
|
<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:
|
Status only example:
|
||||||
|
|
||||||
@@ -836,6 +836,29 @@ Default slash command settings:
|
|||||||
- 4: Custom (uses the activity text as the status state; emoji is optional)
|
- 4: Custom (uses the activity text as the status state; emoji is optional)
|
||||||
- 5: Competing
|
- 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>
|
||||||
|
|
||||||
<Accordion title="Exec approvals in Discord">
|
<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).
|
- `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.
|
- 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.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).
|
- `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).
|
**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).
|
* 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)) {
|
if (isAuthCooldownBypassedForProvider(store.profiles[profileId]?.provider)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,8 @@ export function isProfileInCooldown(store: AuthProfileStore, profileId: string):
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const unusableUntil = resolveProfileUnusableUntil(stats);
|
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 {
|
function isActiveUnusableWindow(until: number | undefined, now: number): boolean {
|
||||||
|
|||||||
@@ -64,4 +64,37 @@ describe("config discord presence", () => {
|
|||||||
|
|
||||||
expect(res.ok).toBe(false);
|
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.",
|
"Optional PluralKit token for resolving private systems or members.",
|
||||||
"channels.discord.activity": "Discord presence activity text (defaults to custom status).",
|
"channels.discord.activity": "Discord presence activity text (defaults to custom status).",
|
||||||
"channels.discord.status": "Discord presence status (online, dnd, idle, invisible).",
|
"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":
|
"channels.discord.activityType":
|
||||||
"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).",
|
"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).",
|
"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.pluralkit.token": "Discord PluralKit Token",
|
||||||
"channels.discord.activity": "Discord Presence Activity",
|
"channels.discord.activity": "Discord Presence Activity",
|
||||||
"channels.discord.status": "Discord Presence Status",
|
"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.activityType": "Discord Presence Activity Type",
|
||||||
"channels.discord.activityUrl": "Discord Presence Activity URL",
|
"channels.discord.activityUrl": "Discord Presence Activity URL",
|
||||||
"channels.slack.dm.policy": "Slack DM Policy",
|
"channels.slack.dm.policy": "Slack DM Policy",
|
||||||
|
|||||||
@@ -190,6 +190,21 @@ export type DiscordSlashCommandConfig = {
|
|||||||
ephemeral?: boolean;
|
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 = {
|
export type DiscordAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -308,6 +323,8 @@ export type DiscordAccountConfig = {
|
|||||||
activity?: string;
|
activity?: string;
|
||||||
/** Bot status (online|dnd|idle|invisible). Defaults to online when presence is configured. */
|
/** Bot status (online|dnd|idle|invisible). Defaults to online when presence is configured. */
|
||||||
status?: "online" | "dnd" | "idle" | "invisible";
|
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. */
|
/** 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;
|
activityType?: 0 | 1 | 2 | 3 | 4 | 5;
|
||||||
/** Streaming URL (Twitch/YouTube). Required when activityType=1. */
|
/** Streaming URL (Twitch/YouTube). Required when activityType=1. */
|
||||||
|
|||||||
@@ -512,6 +512,17 @@ export const DiscordAccountSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
activity: z.string().optional(),
|
activity: z.string().optional(),
|
||||||
status: z.enum(["online", "dnd", "idle", "invisible"]).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
|
activityType: z
|
||||||
.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)])
|
.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)])
|
||||||
.optional(),
|
.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
|
// DM allowlist validation is enforced at DiscordConfigSchema so account entries
|
||||||
// can inherit top-level allowFrom via runtime shallow merge.
|
// 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,
|
clientFetchUserMock,
|
||||||
clientGetPluginMock,
|
clientGetPluginMock,
|
||||||
clientConstructorOptionsMock,
|
clientConstructorOptionsMock,
|
||||||
|
createDiscordAutoPresenceControllerMock,
|
||||||
createDiscordNativeCommandMock,
|
createDiscordNativeCommandMock,
|
||||||
createNoopThreadBindingManagerMock,
|
createNoopThreadBindingManagerMock,
|
||||||
createThreadBindingManagerMock,
|
createThreadBindingManagerMock,
|
||||||
@@ -23,6 +24,13 @@ const {
|
|||||||
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
|
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
|
||||||
return {
|
return {
|
||||||
clientConstructorOptionsMock: vi.fn(),
|
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" })),
|
clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })),
|
||||||
clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined),
|
clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined),
|
||||||
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
|
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
|
||||||
@@ -220,6 +228,10 @@ vi.mock("./presence.js", () => ({
|
|||||||
resolveDiscordPresenceUpdate: () => undefined,
|
resolveDiscordPresenceUpdate: () => undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./auto-presence.js", () => ({
|
||||||
|
createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./provider.allowlist.js", () => ({
|
vi.mock("./provider.allowlist.js", () => ({
|
||||||
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
|
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
|
||||||
}));
|
}));
|
||||||
@@ -268,6 +280,13 @@ describe("monitorDiscordProvider", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clientConstructorOptionsMock.mockClear();
|
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" });
|
clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" });
|
||||||
clientGetPluginMock.mockClear().mockReturnValue(undefined);
|
clientGetPluginMock.mockClear().mockReturnValue(undefined);
|
||||||
createDiscordNativeCommandMock.mockClear().mockReturnValue({ name: "mock-command" });
|
createDiscordNativeCommandMock.mockClear().mockReturnValue({ name: "mock-command" });
|
||||||
@@ -385,4 +404,24 @@ describe("monitorDiscordProvider", () => {
|
|||||||
const eventQueue = getConstructedEventQueue();
|
const eventQueue = getConstructedEventQueue();
|
||||||
expect(eventQueue?.listenerTimeout).toBe(300_000);
|
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,
|
createDiscordComponentStringSelect,
|
||||||
createDiscordComponentUserSelect,
|
createDiscordComponentUserSelect,
|
||||||
} from "./agent-components.js";
|
} from "./agent-components.js";
|
||||||
|
import { createDiscordAutoPresenceController } from "./auto-presence.js";
|
||||||
import { resolveDiscordSlashCommandConfig } from "./commands.js";
|
import { resolveDiscordSlashCommandConfig } from "./commands.js";
|
||||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||||
import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
|
import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
|
||||||
@@ -356,6 +357,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
let lifecycleStarted = false;
|
let lifecycleStarted = false;
|
||||||
let releaseEarlyGatewayErrorGuard = () => {};
|
let releaseEarlyGatewayErrorGuard = () => {};
|
||||||
|
let autoPresenceController: ReturnType<typeof createDiscordAutoPresenceController> | null = null;
|
||||||
try {
|
try {
|
||||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||||
createDiscordNativeCommand({
|
createDiscordNativeCommand({
|
||||||
@@ -450,6 +452,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
class DiscordStatusReadyListener extends ReadyListener {
|
class DiscordStatusReadyListener extends ReadyListener {
|
||||||
async handle(_data: unknown, client: Client) {
|
async handle(_data: unknown, client: Client) {
|
||||||
|
if (autoPresenceController?.enabled) {
|
||||||
|
autoPresenceController.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||||
if (!gateway) {
|
if (!gateway) {
|
||||||
return;
|
return;
|
||||||
@@ -497,6 +504,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const earlyGatewayErrorGuard = attachEarlyGatewayErrorGuard(client);
|
const earlyGatewayErrorGuard = attachEarlyGatewayErrorGuard(client);
|
||||||
releaseEarlyGatewayErrorGuard = earlyGatewayErrorGuard.release;
|
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 });
|
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||||
|
|
||||||
const logger = createSubsystemLogger("discord/monitor");
|
const logger = createSubsystemLogger("discord/monitor");
|
||||||
@@ -598,6 +616,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const botIdentity =
|
const botIdentity =
|
||||||
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
|
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
|
||||||
runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`);
|
runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`);
|
||||||
|
if (lifecycleGateway?.isConnected) {
|
||||||
|
opts.setStatus?.({ connected: true });
|
||||||
|
}
|
||||||
|
|
||||||
lifecycleStarted = true;
|
lifecycleStarted = true;
|
||||||
await runDiscordGatewayLifecycle({
|
await runDiscordGatewayLifecycle({
|
||||||
@@ -615,6 +636,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
releaseEarlyGatewayErrorGuard,
|
releaseEarlyGatewayErrorGuard,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
autoPresenceController?.stop();
|
||||||
|
opts.setStatus?.({ connected: false });
|
||||||
releaseEarlyGatewayErrorGuard();
|
releaseEarlyGatewayErrorGuard();
|
||||||
if (!lifecycleStarted) {
|
if (!lifecycleStarted) {
|
||||||
threadBindings.stop();
|
threadBindings.stop();
|
||||||
|
|||||||
Reference in New Issue
Block a user