This commit is contained in:
itsahedge 2026-01-30 20:15:58 +05:30 committed by GitHub
commit 517b7b2d8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 479 additions and 0 deletions

View File

@ -328,6 +328,10 @@ const FIELD_LABELS: Record<string, string> = {
"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",
@ -674,6 +678,14 @@ const FIELD_HELP: Record<string, string> = {
"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=["*"].',
};

View File

@ -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 = {

View File

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

View File

@ -0,0 +1,151 @@
import { describe, it, expect } 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");
});
});
});

View File

@ -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<string, string> = {
"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<BotPresenceVars> {
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<void> {
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}"`);
}

View File

@ -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<GatewayPlugin>("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,