mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:21:23 +00:00
feat(discord): add set-presence action for bot activity and status
Bridge the agent tools layer to the Discord gateway WebSocket via a new gateway registry, allowing agents to set the bot's activity and online status. Supports playing, streaming, listening, watching, custom, and competing activity types. Custom type uses activityState as the sidebar text; other types show activityName in the sidebar and activityState in the flyout. Opt-in via channels.discord.actions.presence (default false). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
clawdinator[bot]
parent
b64c1a56a1
commit
5af322f710
204
src/agents/tools/discord-actions-presence.test.ts
Normal file
204
src/agents/tools/discord-actions-presence.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import type { ActionGate } from "./common.js";
|
||||
import { clearGateways, registerGateway } from "../../discord/monitor/gateway-registry.js";
|
||||
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
|
||||
|
||||
const mockUpdatePresence = vi.fn();
|
||||
|
||||
function createMockGateway(connected = true): GatewayPlugin {
|
||||
return { isConnected: connected, updatePresence: mockUpdatePresence } as unknown as GatewayPlugin;
|
||||
}
|
||||
|
||||
const presenceEnabled: ActionGate<DiscordActionConfig> = (key) => key === "presence";
|
||||
const presenceDisabled: ActionGate<DiscordActionConfig> = () => false;
|
||||
|
||||
describe("handleDiscordPresenceAction", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdatePresence.mockClear();
|
||||
clearGateways();
|
||||
registerGateway(undefined, createMockGateway());
|
||||
});
|
||||
|
||||
it("sets playing activity", async () => {
|
||||
const result = await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "playing", activityName: "with fire", status: "online" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "with fire", type: 0 }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
const payload = JSON.parse(result.content[0].text ?? "");
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.activities[0]).toEqual({ type: 0, name: "with fire" });
|
||||
});
|
||||
|
||||
it("sets streaming activity with optional URL", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{
|
||||
activityType: "streaming",
|
||||
activityName: "My Stream",
|
||||
activityUrl: "https://twitch.tv/example",
|
||||
},
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "My Stream", type: 1, url: "https://twitch.tv/example" }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows streaming without URL", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "streaming", activityName: "My Stream" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "My Stream", type: 1 }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets listening activity", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "listening", activityName: "Spotify" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activities: [{ name: "Spotify", type: 2 }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets watching activity", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "watching", activityName: "you" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activities: [{ name: "you", type: 3 }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets custom activity using state", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "custom", activityState: "Vibing" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "", type: 4, state: "Vibing" }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes activityState", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "playing", activityName: "My Game", activityState: "In the lobby" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [{ name: "My Game", type: 0, state: "In the lobby" }],
|
||||
status: "online",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets status-only without activity", async () => {
|
||||
await handleDiscordPresenceAction("setPresence", { status: "idle" }, presenceEnabled);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith({
|
||||
since: null,
|
||||
activities: [],
|
||||
status: "idle",
|
||||
afk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults status to online", async () => {
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ activityType: "playing", activityName: "test" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(expect.objectContaining({ status: "online" }));
|
||||
});
|
||||
|
||||
it("rejects invalid status", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "offline" }, presenceEnabled),
|
||||
).rejects.toThrow(/Invalid status/);
|
||||
});
|
||||
|
||||
it("rejects invalid activity type", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { activityType: "invalid" }, presenceEnabled),
|
||||
).rejects.toThrow(/Invalid activityType/);
|
||||
});
|
||||
|
||||
it("respects presence gating", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "online" }, presenceDisabled),
|
||||
).rejects.toThrow(/disabled/);
|
||||
});
|
||||
|
||||
it("errors when gateway is not registered", async () => {
|
||||
clearGateways();
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "dnd" }, presenceEnabled),
|
||||
).rejects.toThrow(/not available/);
|
||||
});
|
||||
|
||||
it("errors when gateway is not connected", async () => {
|
||||
clearGateways();
|
||||
registerGateway(undefined, createMockGateway(false));
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { status: "dnd" }, presenceEnabled),
|
||||
).rejects.toThrow(/not connected/);
|
||||
});
|
||||
|
||||
it("uses accountId to resolve gateway", async () => {
|
||||
const accountGateway = createMockGateway();
|
||||
registerGateway("my-account", accountGateway);
|
||||
await handleDiscordPresenceAction(
|
||||
"setPresence",
|
||||
{ accountId: "my-account", activityType: "playing", activityName: "test" },
|
||||
presenceEnabled,
|
||||
);
|
||||
expect(mockUpdatePresence).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults activity name to empty string when only type is provided", async () => {
|
||||
await handleDiscordPresenceAction("setPresence", { activityType: "playing" }, presenceEnabled);
|
||||
expect(mockUpdatePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activities: [{ name: "", type: 0 }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown presence actions", async () => {
|
||||
await expect(handleDiscordPresenceAction("unknownAction", {}, presenceEnabled)).rejects.toThrow(
|
||||
/Unknown presence action/,
|
||||
);
|
||||
});
|
||||
});
|
||||
105
src/agents/tools/discord-actions-presence.ts
Normal file
105
src/agents/tools/discord-actions-presence.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { getGateway } from "../../discord/monitor/gateway-registry.js";
|
||||
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
|
||||
|
||||
const ACTIVITY_TYPE_MAP: Record<string, number> = {
|
||||
playing: 0,
|
||||
streaming: 1,
|
||||
listening: 2,
|
||||
watching: 3,
|
||||
custom: 4,
|
||||
competing: 5,
|
||||
};
|
||||
|
||||
const VALID_STATUSES = new Set(["online", "dnd", "idle", "invisible"]);
|
||||
|
||||
export async function handleDiscordPresenceAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
if (action !== "setPresence") {
|
||||
throw new Error(`Unknown presence action: ${action}`);
|
||||
}
|
||||
|
||||
if (!isActionEnabled("presence", false)) {
|
||||
throw new Error("Discord presence changes are disabled.");
|
||||
}
|
||||
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
const gateway = getGateway(accountId);
|
||||
if (!gateway) {
|
||||
throw new Error(
|
||||
`Discord gateway not available${accountId ? ` for account "${accountId}"` : ""}. The bot may not be connected.`,
|
||||
);
|
||||
}
|
||||
if (!gateway.isConnected) {
|
||||
throw new Error(
|
||||
`Discord gateway is not connected${accountId ? ` for account "${accountId}"` : ""}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const statusRaw = readStringParam(params, "status") ?? "online";
|
||||
if (!VALID_STATUSES.has(statusRaw)) {
|
||||
throw new Error(
|
||||
`Invalid status "${statusRaw}". Must be one of: ${[...VALID_STATUSES].join(", ")}`,
|
||||
);
|
||||
}
|
||||
const status = statusRaw as UpdatePresenceData["status"];
|
||||
|
||||
const activityTypeRaw = readStringParam(params, "activityType");
|
||||
const activityName = readStringParam(params, "activityName");
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
if (activityTypeRaw || activityName) {
|
||||
const typeNum = activityTypeRaw ? ACTIVITY_TYPE_MAP[activityTypeRaw.toLowerCase()] : 0;
|
||||
if (typeNum === undefined) {
|
||||
throw new Error(
|
||||
`Invalid activityType "${activityTypeRaw}". Must be one of: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const activity: Activity = {
|
||||
name: activityName ?? "",
|
||||
type: typeNum,
|
||||
};
|
||||
|
||||
// Streaming URL (Twitch/YouTube). May not render for bots but is the correct payload shape.
|
||||
if (typeNum === 1) {
|
||||
const url = readStringParam(params, "activityUrl");
|
||||
if (url) {
|
||||
activity.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
const state = readStringParam(params, "activityState");
|
||||
if (state) {
|
||||
activity.state = state;
|
||||
}
|
||||
|
||||
activities.push(activity);
|
||||
}
|
||||
|
||||
const presenceData: UpdatePresenceData = {
|
||||
since: null,
|
||||
activities,
|
||||
status,
|
||||
afk: false,
|
||||
};
|
||||
|
||||
gateway.updatePresence(presenceData);
|
||||
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
status,
|
||||
activities: activities.map((a) => ({
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
...(a.url ? { url: a.url } : {}),
|
||||
...(a.state ? { state: a.state } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createActionGate, readStringParam } from "./common.js";
|
||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
|
||||
|
||||
const messagingActions = new Set([
|
||||
"react",
|
||||
@@ -51,6 +52,8 @@ const guildActions = new Set([
|
||||
|
||||
const moderationActions = new Set(["timeout", "kick", "ban"]);
|
||||
|
||||
const presenceActions = new Set(["setPresence"]);
|
||||
|
||||
export async function handleDiscordAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
@@ -67,5 +70,8 @@ export async function handleDiscordAction(
|
||||
if (moderationActions.has(action)) {
|
||||
return await handleDiscordModerationAction(action, params, isActionEnabled);
|
||||
}
|
||||
if (presenceActions.has(action)) {
|
||||
return await handleDiscordPresenceAction(action, params, isActionEnabled);
|
||||
}
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,36 @@ function buildGatewaySchema() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPresenceSchema() {
|
||||
return {
|
||||
activityType: Type.Optional(
|
||||
Type.String({
|
||||
description: "Activity type: playing, streaming, listening, watching, competing, custom.",
|
||||
}),
|
||||
),
|
||||
activityName: Type.Optional(
|
||||
Type.String({
|
||||
description: "Activity name shown in sidebar (e.g. 'with fire'). Ignored for custom type.",
|
||||
}),
|
||||
),
|
||||
activityUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Streaming URL (Twitch or YouTube). Only used with streaming type; may not render for bots.",
|
||||
}),
|
||||
),
|
||||
activityState: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"State text. For custom type this is the status text; for others it shows in the flyout.",
|
||||
}),
|
||||
),
|
||||
status: Type.Optional(
|
||||
Type.String({ description: "Bot status: online, dnd, idle, invisible." }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildChannelManagementSchema() {
|
||||
return {
|
||||
name: Type.Optional(Type.String()),
|
||||
@@ -225,6 +255,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
|
||||
...buildModerationSchema(),
|
||||
...buildGatewaySchema(),
|
||||
...buildChannelManagementSchema(),
|
||||
...buildPresenceSchema(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user