From 6acea69b20a3e2db384242746d74f70144b58fba Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 13 Feb 2026 13:12:16 -0600 Subject: [PATCH] Discord: refine presence config defaults (#10855) (thanks @h0tp-ftw) --- CHANGELOG.md | 2 + src/config/config.discord-presence.test.ts | 67 ++++++++++++++++++++++ src/config/schema.help.ts | 5 ++ src/config/schema.labels.ts | 4 ++ src/config/types.discord.ts | 8 +-- src/config/zod-schema.providers-core.ts | 37 +++++++++++- src/discord/monitor/presence.test.ts | 42 ++++++++++++++ src/discord/monitor/presence.ts | 49 ++++++++++++++++ src/discord/monitor/provider.ts | 1 + 9 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 src/config/config.discord-presence.test.ts create mode 100644 src/discord/monitor/presence.test.ts create mode 100644 src/discord/monitor/presence.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fe13e837..679fed1919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path. - Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou. +- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw. ### Fixes @@ -252,6 +253,7 @@ Docs: https://docs.openclaw.ai - CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617. - CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr. - Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids. +- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw. ### Added diff --git a/src/config/config.discord-presence.test.ts b/src/config/config.discord-presence.test.ts new file mode 100644 index 0000000000..4ecacfab19 --- /dev/null +++ b/src/config/config.discord-presence.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("config discord presence", () => { + it("accepts status-only presence", () => { + const res = validateConfigObject({ + channels: { + discord: { + status: "idle", + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts custom activity when type is omitted", () => { + const res = validateConfigObject({ + channels: { + discord: { + activity: "Focus time", + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts custom activity type", () => { + const res = validateConfigObject({ + channels: { + discord: { + activity: "Chilling", + activityType: 4, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("rejects streaming activity without url", () => { + const res = validateConfigObject({ + channels: { + discord: { + activity: "Live", + activityType: 1, + }, + }, + }); + + expect(res.ok).toBe(false); + }); + + it("rejects activityUrl without streaming type", () => { + const res = validateConfigObject({ + channels: { + discord: { + activity: "Live", + activityUrl: "https://twitch.tv/openclaw", + }, + }, + }); + + expect(res.ok).toBe(false); + }); +}); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 52841428c0..9f1fe795af 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -369,6 +369,11 @@ export const FIELD_HELP: Record = { "Resolve PluralKit proxied messages and treat system members as distinct senders.", "channels.discord.pluralkit.token": "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.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).", "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', }; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index a91e89360f..5f0b0a5352 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -259,6 +259,10 @@ export const FIELD_LABELS: Record = { "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", "channels.discord.pluralkit.token": "Discord PluralKit Token", + "channels.discord.activity": "Discord Presence Activity", + "channels.discord.status": "Discord Presence Status", + "channels.discord.activityType": "Discord Presence Activity Type", + "channels.discord.activityUrl": "Discord Presence Activity URL", "channels.slack.dm.policy": "Slack DM Policy", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.token": "Discord Bot Token", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index f294217854..b6ec535e31 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -177,11 +177,11 @@ export type DiscordAccountConfig = { responsePrefix?: string; /** Bot activity status text (e.g. "Watching X"). */ activity?: string; - /** Bot status (online|dnd|idle|invisible). Default: online. */ + /** Bot status (online|dnd|idle|invisible). Defaults to online when presence is configured. */ status?: "online" | "dnd" | "idle" | "invisible"; - /** Activity type (0=Game, 1=Streaming, 2=Listening, 3=Watching, 5=Competing). Default: 3 (Watching). */ - activityType?: 0 | 1 | 2 | 3 | 5; - /** Streaming URL (Twitch/YouTube). Required if activityType=1. */ + /** 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. */ activityUrl?: string; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index f8c246cfbc..ab6d198af9 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -335,11 +335,42 @@ export const DiscordAccountSchema = z activity: z.string().optional(), status: z.enum(["online", "dnd", "idle", "invisible"]).optional(), activityType: z - .union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(5)]) + .union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]) .optional(), - activityUrl: z.string().optional(), + activityUrl: z.string().url().optional(), }) - .strict(); + .strict() + .superRefine((value, ctx) => { + const activityText = typeof value.activity === "string" ? value.activity.trim() : ""; + const hasActivity = Boolean(activityText); + const hasActivityType = value.activityType !== undefined; + const activityUrl = typeof value.activityUrl === "string" ? value.activityUrl.trim() : ""; + const hasActivityUrl = Boolean(activityUrl); + + if ((hasActivityType || hasActivityUrl) && !hasActivity) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "channels.discord.activity is required when activityType or activityUrl is set", + path: ["activity"], + }); + } + + if (value.activityType === 1 && !hasActivityUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "channels.discord.activityUrl is required when activityType is 1 (Streaming)", + path: ["activityUrl"], + }); + } + + if (hasActivityUrl && value.activityType !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "channels.discord.activityType must be 1 (Streaming) when activityUrl is set", + path: ["activityType"], + }); + } + }); export const DiscordConfigSchema = DiscordAccountSchema.extend({ accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(), diff --git a/src/discord/monitor/presence.test.ts b/src/discord/monitor/presence.test.ts new file mode 100644 index 0000000000..83fd15efaf --- /dev/null +++ b/src/discord/monitor/presence.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { resolveDiscordPresenceUpdate } from "./presence.js"; + +describe("resolveDiscordPresenceUpdate", () => { + it("returns null when no presence config provided", () => { + expect(resolveDiscordPresenceUpdate({})).toBeNull(); + }); + + it("returns status-only presence when activity is omitted", () => { + const presence = resolveDiscordPresenceUpdate({ status: "dnd" }); + expect(presence).not.toBeNull(); + expect(presence?.status).toBe("dnd"); + expect(presence?.activities).toEqual([]); + }); + + it("defaults to custom activity type when activity is set without type", () => { + const presence = resolveDiscordPresenceUpdate({ activity: "Focus time" }); + expect(presence).not.toBeNull(); + expect(presence?.status).toBe("online"); + expect(presence?.activities).toHaveLength(1); + expect(presence?.activities[0]).toMatchObject({ + type: 4, + name: "Custom Status", + state: "Focus time", + }); + }); + + it("includes streaming url when activityType is streaming", () => { + const presence = resolveDiscordPresenceUpdate({ + activity: "Live", + activityType: 1, + activityUrl: "https://twitch.tv/openclaw", + }); + expect(presence).not.toBeNull(); + expect(presence?.activities).toHaveLength(1); + expect(presence?.activities[0]).toMatchObject({ + type: 1, + name: "Live", + url: "https://twitch.tv/openclaw", + }); + }); +}); diff --git a/src/discord/monitor/presence.ts b/src/discord/monitor/presence.ts new file mode 100644 index 0000000000..85da7c0d5b --- /dev/null +++ b/src/discord/monitor/presence.ts @@ -0,0 +1,49 @@ +import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; +import type { DiscordAccountConfig } from "../../config/config.js"; + +const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4; +const CUSTOM_STATUS_NAME = "Custom Status"; + +type DiscordPresenceConfig = Pick< + DiscordAccountConfig, + "activity" | "status" | "activityType" | "activityUrl" +>; + +export function resolveDiscordPresenceUpdate( + config: DiscordPresenceConfig, +): UpdatePresenceData | null { + const activityText = typeof config.activity === "string" ? config.activity.trim() : ""; + const status = typeof config.status === "string" ? config.status.trim() : ""; + const activityType = config.activityType; + const activityUrl = typeof config.activityUrl === "string" ? config.activityUrl.trim() : ""; + + const hasActivity = Boolean(activityText); + const hasStatus = Boolean(status); + + if (!hasActivity && !hasStatus) { + return null; + } + + const activities: Activity[] = []; + + if (hasActivity) { + const resolvedType = activityType ?? DEFAULT_CUSTOM_ACTIVITY_TYPE; + const activity: Activity = + resolvedType === DEFAULT_CUSTOM_ACTIVITY_TYPE + ? { name: CUSTOM_STATUS_NAME, type: resolvedType, state: activityText } + : { name: activityText, type: resolvedType }; + + if (resolvedType === 1 && activityUrl) { + activity.url = activityUrl; + } + + activities.push(activity); + } + + return { + since: null, + activities, + status: (status || "online") as UpdatePresenceData["status"], + afk: false, + }; +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 06365b1fd9..46bd2357d7 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -45,6 +45,7 @@ import { createDiscordCommandArgFallbackButton, createDiscordNativeCommand, } from "./native-command.js"; +import { resolveDiscordPresenceUpdate } from "./presence.js"; export type MonitorDiscordOpts = { token?: string;