mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Discord: refine presence config defaults (#10855) (thanks @h0tp-ftw)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
67
src/config/config.discord-presence.test.ts
Normal file
67
src/config/config.discord-presence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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=["*"].',
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
42
src/discord/monitor/presence.test.ts
Normal file
42
src/discord/monitor/presence.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/discord/monitor/presence.ts
Normal file
49
src/discord/monitor/presence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
createDiscordCommandArgFallbackButton,
|
||||
createDiscordNativeCommand,
|
||||
} from "./native-command.js";
|
||||
import { resolveDiscordPresenceUpdate } from "./presence.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
|
||||
Reference in New Issue
Block a user