Discord: refine presence config defaults (#10855) (thanks @h0tp-ftw)

This commit is contained in:
Shadow
2026-02-13 13:12:16 -06:00
committed by Shadow
parent 770e904c21
commit 6acea69b20
9 changed files with 208 additions and 7 deletions

View File

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

View File

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

View File

@@ -369,6 +369,11 @@ export const FIELD_HELP: Record<string, string> = {
"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=["*"].',
};

View File

@@ -259,6 +259,10 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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;
};

View File

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

View File

@@ -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",
});
});
});

View File

@@ -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,
};
}

View File

@@ -45,6 +45,7 @@ import {
createDiscordCommandArgFallbackButton,
createDiscordNativeCommand,
} from "./native-command.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
export type MonitorDiscordOpts = {
token?: string;