From 04412e07bd87686f9e2bd2d4b1fa8023e134be21 Mon Sep 17 00:00:00 2001 From: itsahedge Date: Wed, 28 Jan 2026 12:38:41 -0500 Subject: [PATCH 1/3] feat(discord): add bot presence config for model/auth display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds configurable Discord bot presence to show current model and auth profile. Config options: - presence.enabled: Enable dynamic presence - presence.template: Custom template with {model}, {modelFull}, {authProfile}, {provider} - presence.activityType: Discord activity type (0-5) - presence.status: Bot status (online, idle, dnd, invisible) Example: ```json { "channels": { "discord": { "presence": { "enabled": true, "template": "{model} • {authProfile}", "status": "online" } } } } ``` Closes #3464 --- src/config/schema.ts | 12 ++ src/config/types.discord.ts | 23 +++ src/config/zod-schema.providers-core.ts | 18 ++ src/discord/monitor/bot-presence.ts | 261 ++++++++++++++++++++++++ src/discord/monitor/provider.ts | 14 ++ 5 files changed, 328 insertions(+) create mode 100644 src/discord/monitor/bot-presence.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..5cc4cde29 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -327,6 +327,10 @@ const FIELD_LABELS: Record = { "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", "channels.discord.intents.presence": "Discord Presence Intent", "channels.discord.intents.guildMembers": "Discord Guild Members Intent", + "channels.discord.presence.enabled": "Discord Bot Presence Enabled", + "channels.discord.presence.template": "Discord Bot Presence Template", + "channels.discord.presence.activityType": "Discord Bot Presence Activity Type", + "channels.discord.presence.status": "Discord Bot Presence Status", "channels.slack.dm.policy": "Slack DM Policy", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.token": "Discord Bot Token", @@ -671,6 +675,14 @@ const FIELD_HELP: Record = { "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", "channels.discord.intents.guildMembers": "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", + "channels.discord.presence.enabled": + "Enable dynamic bot presence showing current model and auth profile. Default: false.", + "channels.discord.presence.template": + 'Template for the presence activity text. Variables: {model}, {modelFull}, {authProfile}, {provider}. Default: "{model} • {authProfile}".', + "channels.discord.presence.activityType": + "Discord activity type: 0=Playing, 1=Streaming, 2=Listening, 3=Watching, 4=Custom, 5=Competing. Default: 4 (Custom).", + "channels.discord.presence.status": + "Bot status: online, idle, dnd, or invisible. Default: online.", "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 07d4e658f..49c26b0ae 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -81,6 +81,27 @@ export type DiscordIntentsConfig = { guildMembers?: boolean; }; +export type DiscordPresenceActivityType = 0 | 1 | 2 | 3 | 4 | 5; + +export type DiscordPresenceConfig = { + /** Enable dynamic bot presence showing model/auth info. Default: false. */ + enabled?: boolean; + /** + * Template for the activity text. + * Variables: {model}, {modelFull}, {authProfile}, {provider}. + * Default: "{model} • {authProfile}". + */ + template?: string; + /** + * Discord activity type: + * 0 = Playing, 1 = Streaming, 2 = Listening, 3 = Watching, 4 = Custom, 5 = Competing. + * Default: 4 (Custom). + */ + activityType?: DiscordPresenceActivityType; + /** Bot status: online, idle, dnd, invisible. Default: online. */ + status?: "online" | "idle" | "dnd" | "invisible"; +}; + export type DiscordExecApprovalConfig = { /** Enable exec approval forwarding to Discord DMs. Default: false. */ enabled?: boolean; @@ -150,6 +171,8 @@ export type DiscordAccountConfig = { execApprovals?: DiscordExecApprovalConfig; /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */ intents?: DiscordIntentsConfig; + /** Bot presence (status/activity) showing current model and auth profile. */ + presence?: DiscordPresenceConfig; }; export type DiscordConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..4500c66bd 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -275,6 +275,24 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + presence: z + .object({ + enabled: z.boolean().optional(), + template: z.string().optional(), + activityType: z + .union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + ]) + .optional(), + status: z.enum(["online", "idle", "dnd", "invisible"]).optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/discord/monitor/bot-presence.ts b/src/discord/monitor/bot-presence.ts new file mode 100644 index 000000000..978f4dc7b --- /dev/null +++ b/src/discord/monitor/bot-presence.ts @@ -0,0 +1,261 @@ +/** + * Discord bot presence management. + * + * Sets bot status/activity to show current model and auth profile info. + */ +import type { GatewayPlugin } from "@buape/carbon/gateway"; +import type { MoltbotConfig } from "../../config/config.js"; +import type { DiscordPresenceConfig } from "../../config/types.discord.js"; +import { resolveDefaultModelForAgent, modelKey } from "../../agents/model-selection.js"; +import { loadModelCatalog, type ModelCatalogEntry } from "../../agents/model-catalog.js"; + +const DEFAULT_TEMPLATE = "{model} • {authProfile}"; + +/** Friendly model name mappings for common models. */ +const FRIENDLY_MODEL_NAMES: Record = { + "claude-opus-4-5": "Opus 4.5", + "claude-opus-4": "Opus 4", + "claude-sonnet-4-5": "Sonnet 4.5", + "claude-sonnet-4": "Sonnet 4", + "claude-3-opus": "Opus 3", + "claude-3-5-sonnet": "Sonnet 3.5", + "claude-3-5-haiku": "Haiku 3.5", + "claude-3-sonnet": "Sonnet 3", + "claude-3-haiku": "Haiku 3", + "gpt-4o": "GPT-4o", + "gpt-4o-mini": "GPT-4o Mini", + "gpt-4-turbo": "GPT-4 Turbo", + "gpt-4": "GPT-4", + "gpt-3.5-turbo": "GPT-3.5", + o1: "o1", + "o1-mini": "o1 Mini", + "o1-preview": "o1 Preview", + o3: "o3", + "o3-mini": "o3 Mini", + "gemini-2.0-flash": "Gemini 2.0 Flash", + "gemini-1.5-pro": "Gemini 1.5 Pro", + "gemini-1.5-flash": "Gemini 1.5 Flash", + "gemini-3-pro": "Gemini 3 Pro", + "gemini-3-flash": "Gemini 3 Flash", + "gpt-5.1-codex": "GPT-5.1 Codex", + "gpt-5.1": "GPT-5.1", + "gpt-5.2": "GPT-5.2", +}; + +export type BotPresenceVars = { + /** Friendly model name (e.g., "Opus 4.5"). */ + model: string; + /** Full model ID (e.g., "anthropic/claude-opus-4-5"). */ + modelFull: string; + /** Auth profile ID (e.g., "anthropic:work"). */ + authProfile: string; + /** Provider name (e.g., "anthropic"). */ + provider: string; +}; + +/** + * Resolve a friendly model name from a model ID. + * Checks the model catalog first, then falls back to built-in mappings. + */ +function resolveFriendlyModelName(modelId: string, catalog: ModelCatalogEntry[]): string { + // Check catalog for a name + const catalogEntry = catalog.find( + (entry) => entry.id === modelId || entry.id.endsWith(`/${modelId}`), + ); + if (catalogEntry?.name) { + return catalogEntry.name; + } + + // Check built-in friendly names + if (FRIENDLY_MODEL_NAMES[modelId]) { + return FRIENDLY_MODEL_NAMES[modelId]; + } + + // Fall back to title-casing the model ID + return modelId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Normalize provider ID to lowercase. + */ +function normalizeProvider(provider: string): string { + return provider.toLowerCase().trim(); +} + +/** + * Resolve the default auth profile ID for a provider from config. + * Checks auth.order first, then looks for any profile matching the provider. + */ +function resolveDefaultAuthProfile(cfg: MoltbotConfig, provider: string): string { + const providerKey = normalizeProvider(provider); + + // Check auth.order for explicit ordering + const authOrder = cfg.auth?.order; + if (authOrder) { + for (const [key, value] of Object.entries(authOrder)) { + if (normalizeProvider(key) === providerKey && Array.isArray(value) && value.length > 0) { + return value[0]; + } + } + } + + // Check auth.profiles for any profile matching this provider + const profiles = cfg.auth?.profiles; + if (profiles) { + for (const [profileId, profile] of Object.entries(profiles)) { + if (normalizeProvider(profile.provider) === providerKey) { + return profileId; + } + } + } + + // Fall back to provider:default pattern + return `${provider}:default`; +} + +/** + * Resolve presence template variables from config. + */ +export async function resolveBotPresenceVars(cfg: MoltbotConfig): Promise { + const defaultModel = resolveDefaultModelForAgent({ cfg }); + const provider = defaultModel.provider; + const modelId = defaultModel.model; + const fullModelKey = modelKey(provider, modelId); + + // Try to get a friendly name from the model catalog + let friendlyName: string; + try { + const catalog = await loadModelCatalog({ config: cfg, useCache: true }); + friendlyName = resolveFriendlyModelName(modelId, catalog); + } catch { + friendlyName = resolveFriendlyModelName(modelId, []); + } + + const authProfile = resolveDefaultAuthProfile(cfg, provider); + + return { + model: friendlyName, + modelFull: fullModelKey, + authProfile, + provider, + }; +} + +/** + * Resolve presence template variables synchronously (without catalog lookup). + * Use this when async is not possible. + */ +export function resolveBotPresenceVarsSync(cfg: MoltbotConfig): BotPresenceVars { + const defaultModel = resolveDefaultModelForAgent({ cfg }); + const provider = defaultModel.provider; + const modelId = defaultModel.model; + const fullModelKey = modelKey(provider, modelId); + + const friendlyName = + FRIENDLY_MODEL_NAMES[modelId] ?? + modelId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + const authProfile = resolveDefaultAuthProfile(cfg, provider); + + return { + model: friendlyName, + modelFull: fullModelKey, + authProfile, + provider, + }; +} + +/** + * Resolve template string with variables. + */ +export function resolvePresenceTemplate(template: string, vars: BotPresenceVars): string { + return template + .replace(/\{model\}/g, vars.model) + .replace(/\{modelFull\}/g, vars.modelFull) + .replace(/\{authProfile\}/g, vars.authProfile) + .replace(/\{provider\}/g, vars.provider); +} + +export type UpdateBotPresenceOptions = { + /** Discord config presence settings. */ + presenceConfig?: DiscordPresenceConfig; + /** Pre-resolved presence variables (skips resolution if provided). */ + vars?: BotPresenceVars; + /** Logger function. */ + log?: (msg: string) => void; +}; + +/** + * Update Discord bot presence via the gateway plugin. + */ +export async function updateBotPresence( + gateway: GatewayPlugin, + cfg: MoltbotConfig, + opts: UpdateBotPresenceOptions = {}, +): Promise { + const presenceConfig = opts.presenceConfig; + if (!presenceConfig?.enabled) { + return; + } + + const vars = opts.vars ?? (await resolveBotPresenceVars(cfg)); + const template = presenceConfig.template ?? DEFAULT_TEMPLATE; + const activityText = resolvePresenceTemplate(template, vars); + + // Activity type: 0=Playing, 1=Streaming, 2=Listening, 3=Watching, 4=Custom, 5=Competing + const activityType = presenceConfig.activityType ?? 4; + const status = presenceConfig.status ?? "online"; + + gateway.updatePresence({ + since: null, + afk: false, + status, + activities: [ + { + name: activityText, + type: activityType, + // For custom status (type 4), the name is shown as the status text + // For other types, it shows as "Playing {name}", "Watching {name}", etc. + state: activityType === 4 ? activityText : undefined, + }, + ], + }); + + opts.log?.(`discord: bot presence set to "${activityText}"`); +} + +/** + * Update Discord bot presence synchronously (uses sync var resolution). + */ +export function updateBotPresenceSync( + gateway: GatewayPlugin, + cfg: MoltbotConfig, + opts: UpdateBotPresenceOptions = {}, +): void { + const presenceConfig = opts.presenceConfig; + if (!presenceConfig?.enabled) { + return; + } + + const vars = opts.vars ?? resolveBotPresenceVarsSync(cfg); + const template = presenceConfig.template ?? DEFAULT_TEMPLATE; + const activityText = resolvePresenceTemplate(template, vars); + + const activityType = presenceConfig.activityType ?? 4; + const status = presenceConfig.status ?? "online"; + + gateway.updatePresence({ + since: null, + afk: false, + status, + activities: [ + { + name: activityText, + type: activityType, + state: activityType === 4 ? activityText : undefined, + }, + ], + }); + + opts.log?.(`discord: bot presence set to "${activityText}"`); +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 3ab56a478..6c27d0307 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -39,6 +39,7 @@ import { createDiscordNativeCommand, } from "./native-command.js"; import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js"; +import { updateBotPresence } from "./bot-presence.js"; export type MonitorDiscordOpts = { token?: string; @@ -557,6 +558,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } const gateway = client.getPlugin("gateway"); + + // Set bot presence if configured + if (gateway && discordCfg.presence?.enabled) { + try { + await updateBotPresence(gateway, cfg, { + presenceConfig: discordCfg.presence, + log: runtime.log ? (msg) => runtime.log?.(msg) : undefined, + }); + } catch (err) { + runtime.error?.(danger(`discord: failed to set bot presence: ${formatErrorMessage(err)}`)); + } + } + const gatewayEmitter = getDiscordGatewayEmitter(gateway); const stopGatewayLogging = attachDiscordGatewayLogging({ emitter: gatewayEmitter, From 80d99e2b40e25e715fa0618db498f70a2ad5563f Mon Sep 17 00:00:00 2001 From: itsahedge Date: Wed, 28 Jan 2026 12:40:49 -0500 Subject: [PATCH 2/3] test(discord): add unit tests for bot-presence module --- src/discord/monitor/bot-presence.test.ts | 151 +++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/discord/monitor/bot-presence.test.ts diff --git a/src/discord/monitor/bot-presence.test.ts b/src/discord/monitor/bot-presence.test.ts new file mode 100644 index 000000000..d45ac1781 --- /dev/null +++ b/src/discord/monitor/bot-presence.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi } from "vitest"; +import { + resolvePresenceTemplate, + resolveBotPresenceVarsSync, + type BotPresenceVars, +} from "./bot-presence.js"; +import type { MoltbotConfig } from "../../config/config.js"; + +describe("bot-presence", () => { + describe("resolvePresenceTemplate", () => { + it("replaces all template variables", () => { + const vars: BotPresenceVars = { + model: "Opus 4.5", + modelFull: "anthropic/claude-opus-4-5", + authProfile: "anthropic:work", + provider: "anthropic", + }; + + const result = resolvePresenceTemplate("{model} • {authProfile}", vars); + expect(result).toBe("Opus 4.5 • anthropic:work"); + }); + + it("replaces modelFull and provider", () => { + const vars: BotPresenceVars = { + model: "Opus 4.5", + modelFull: "anthropic/claude-opus-4-5", + authProfile: "anthropic:work", + provider: "anthropic", + }; + + const result = resolvePresenceTemplate("{modelFull} ({provider})", vars); + expect(result).toBe("anthropic/claude-opus-4-5 (anthropic)"); + }); + + it("handles templates with multiple occurrences", () => { + const vars: BotPresenceVars = { + model: "Opus 4.5", + modelFull: "anthropic/claude-opus-4-5", + authProfile: "anthropic:work", + provider: "anthropic", + }; + + const result = resolvePresenceTemplate("{model} - {model}", vars); + expect(result).toBe("Opus 4.5 - Opus 4.5"); + }); + + it("preserves text without variables", () => { + const vars: BotPresenceVars = { + model: "Opus 4.5", + modelFull: "anthropic/claude-opus-4-5", + authProfile: "anthropic:work", + provider: "anthropic", + }; + + const result = resolvePresenceTemplate("Static text", vars); + expect(result).toBe("Static text"); + }); + }); + + describe("resolveBotPresenceVarsSync", () => { + it("resolves model from config", () => { + const cfg: MoltbotConfig = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + }, + }, + }, + auth: { + order: { + anthropic: ["anthropic:work", "anthropic:personal"], + }, + }, + }; + + const vars = resolveBotPresenceVarsSync(cfg); + expect(vars.modelFull).toBe("anthropic/claude-opus-4-5"); + expect(vars.provider).toBe("anthropic"); + expect(vars.authProfile).toBe("anthropic:work"); + }); + + it("uses friendly model name for known models", () => { + const cfg: MoltbotConfig = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + }, + }, + }, + }; + + const vars = resolveBotPresenceVarsSync(cfg); + expect(vars.model).toBe("Opus 4.5"); + }); + + it("formats unknown model names", () => { + const cfg: MoltbotConfig = { + agents: { + defaults: { + model: { + primary: "anthropic/some-new-model", + }, + }, + }, + }; + + const vars = resolveBotPresenceVarsSync(cfg); + expect(vars.model).toBe("Some New Model"); + }); + + it("finds auth profile from profiles config", () => { + const cfg: MoltbotConfig = { + agents: { + defaults: { + model: { + primary: "openai/gpt-4o", + }, + }, + }, + auth: { + profiles: { + "openai:personal": { + provider: "openai", + mode: "api_key", + }, + }, + }, + }; + + const vars = resolveBotPresenceVarsSync(cfg); + expect(vars.authProfile).toBe("openai:personal"); + }); + + it("falls back to provider:default when no auth config", () => { + const cfg: MoltbotConfig = { + agents: { + defaults: { + model: { + primary: "openai/gpt-4o", + }, + }, + }, + }; + + const vars = resolveBotPresenceVarsSync(cfg); + expect(vars.authProfile).toBe("openai:default"); + }); + }); +}); From 6b8eb8fec9c164fbcd5a9bb066742120cfa27f24 Mon Sep 17 00:00:00 2001 From: itsahedge Date: Wed, 28 Jan 2026 13:36:38 -0500 Subject: [PATCH 3/3] fix: remove unused vi import from bot-presence tests --- src/discord/monitor/bot-presence.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discord/monitor/bot-presence.test.ts b/src/discord/monitor/bot-presence.test.ts index d45ac1781..0d0b74655 100644 --- a/src/discord/monitor/bot-presence.test.ts +++ b/src/discord/monitor/bot-presence.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { resolvePresenceTemplate, resolveBotPresenceVarsSync,