Compare commits
3 Commits
split/gate
...
mkt/discor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
648b6d0f91 | ||
|
|
88cfc1cdd6 | ||
|
|
a24f31f5a9 |
@@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- TBD.
|
||||
- Discord: add set-presence action for bot activity/status. (#8340) Thanks @BinaryMuse.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ In **Bot** → **Privileged Gateway Intents**, enable:
|
||||
- **Message Content Intent** (required to read message text in most guilds; without it you’ll see “Used disallowed intents” or the bot will connect but not react to messages)
|
||||
- **Server Members Intent** (recommended; required for some member/user lookups and allowlist matching in guilds)
|
||||
|
||||
You usually do **not** need **Presence Intent**.
|
||||
You usually do **not** need **Presence Intent**. Setting the bot's own presence (`setPresence` action) uses gateway OP3 and does not require this intent; it is only needed if you want to receive presence updates about other guild members.
|
||||
|
||||
### 3) Generate an invite URL (OAuth2 URL Generator)
|
||||
|
||||
@@ -278,6 +278,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
moderation: false,
|
||||
presence: false,
|
||||
},
|
||||
replyToMode: "off",
|
||||
dm: {
|
||||
@@ -353,6 +354,7 @@ ack reaction after the bot replies.
|
||||
- `channels` (create/edit/delete channels + categories + permissions)
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
- `presence` (bot status/activity, default `false`)
|
||||
- `execApprovals`: Discord-only exec approval DMs (button UI). Supports `enabled`, `approvers`, `agentFilter`, `sessionFilter`.
|
||||
|
||||
Reaction notifications use `guilds.<id>.reactionNotifications`:
|
||||
@@ -412,6 +414,7 @@ Allowlist notes (PK-enabled):
|
||||
| events | enabled | List/create scheduled events |
|
||||
| roles | disabled | Role add/remove |
|
||||
| moderation | disabled | Timeout/kick/ban |
|
||||
| presence | disabled | Bot status/activity (setPresence) |
|
||||
|
||||
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
|
||||
|
||||
@@ -460,6 +463,7 @@ The agent can call `discord` with actions like:
|
||||
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
|
||||
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
|
||||
- `timeout`, `kick`, `ban`
|
||||
- `setPresence` (bot activity and online status)
|
||||
|
||||
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
|
||||
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: discord
|
||||
description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
|
||||
description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, set bot presence/activity, or handle moderation actions in Discord DMs or channels.
|
||||
metadata: {"openclaw":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
|
||||
---
|
||||
|
||||
@@ -139,6 +139,7 @@ Use `discord.actions.*` to disable action groups:
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `channels` (channel/category create/edit/delete/move, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
- `presence` (bot status/activity, default `false`)
|
||||
|
||||
### Read recent messages
|
||||
|
||||
@@ -432,6 +433,101 @@ Create, edit, delete, and move channels and categories. Enable via `discord.acti
|
||||
}
|
||||
```
|
||||
|
||||
### Bot presence/activity (disabled by default)
|
||||
|
||||
Set the bot's online status and activity. Enable via `discord.actions.presence: true`.
|
||||
|
||||
Discord bots can only set `name`, `state`, `type`, and `url` on an activity. Other Activity fields (details, emoji, assets) are accepted by the gateway but silently ignored by Discord for bots.
|
||||
|
||||
**How fields render by activity type:**
|
||||
|
||||
- **playing, streaming, listening, watching, competing**: `activityName` is shown in the sidebar under the bot's name (e.g. "**with fire**" for type "playing" and name "with fire"). `activityState` is shown in the profile flyout.
|
||||
- **custom**: `activityName` is ignored. Only `activityState` is displayed as the status text in the sidebar.
|
||||
- **streaming**: `activityUrl` may be displayed or embedded by the client.
|
||||
|
||||
**Set playing status:**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "playing",
|
||||
"activityName": "with fire"
|
||||
}
|
||||
```
|
||||
|
||||
Result in sidebar: "**with fire**". Flyout shows: "Playing: with fire"
|
||||
|
||||
**With state (shown in flyout):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "playing",
|
||||
"activityName": "My Game",
|
||||
"activityState": "In the lobby"
|
||||
}
|
||||
```
|
||||
|
||||
Result in sidebar: "**My Game**". Flyout shows: "Playing: My Game (newline) In the lobby".
|
||||
|
||||
**Set streaming (optional URL, may not render for bots):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "streaming",
|
||||
"activityName": "Live coding",
|
||||
"activityUrl": "https://twitch.tv/example"
|
||||
}
|
||||
```
|
||||
|
||||
**Set listening/watching:**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "listening",
|
||||
"activityName": "Spotify"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "watching",
|
||||
"activityName": "the logs"
|
||||
}
|
||||
```
|
||||
|
||||
**Set a custom status (text in sidebar):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"activityType": "custom",
|
||||
"activityState": "Vibing"
|
||||
}
|
||||
```
|
||||
|
||||
Result in sidebar: "Vibing". Note: `activityName` is ignored for custom type.
|
||||
|
||||
**Set bot status only (no activity/clear status):**
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "setPresence",
|
||||
"status": "dnd"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `activityType`: `playing`, `streaming`, `listening`, `watching`, `competing`, `custom`
|
||||
- `activityName`: text shown in the sidebar for non-custom types (ignored for `custom`)
|
||||
- `activityUrl`: Twitch or YouTube URL for streaming type (optional; may not render for bots)
|
||||
- `activityState`: for `custom` this is the status text; for other types it shows in the profile flyout
|
||||
- `status`: `online` (default), `dnd`, `idle`, `invisible`
|
||||
|
||||
## Discord Writing Style Guide
|
||||
|
||||
**Keep it conversational!** Discord is a chat platform, not documentation.
|
||||
|
||||
210
src/agents/tools/discord-actions-presence.test.ts
Normal file
210
src/agents/tools/discord-actions-presence.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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("requires activityType when activityName is provided", async () => {
|
||||
await expect(
|
||||
handleDiscordPresenceAction("setPresence", { activityName: "My Game" }, presenceEnabled),
|
||||
).rejects.toThrow(/activityType is required/);
|
||||
});
|
||||
|
||||
it("rejects unknown presence actions", async () => {
|
||||
await expect(handleDiscordPresenceAction("unknownAction", {}, presenceEnabled)).rejects.toThrow(
|
||||
/Unknown presence action/,
|
||||
);
|
||||
});
|
||||
});
|
||||
111
src/agents/tools/discord-actions-presence.ts
Normal file
111
src/agents/tools/discord-actions-presence.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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) {
|
||||
if (!activityTypeRaw) {
|
||||
throw new Error(
|
||||
"activityType is required when activityName is provided. " +
|
||||
`Valid types: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
|
||||
);
|
||||
}
|
||||
const typeNum = ACTIVITY_TYPE_MAP[activityTypeRaw.toLowerCase()];
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
actions.add("kick");
|
||||
actions.add("ban");
|
||||
}
|
||||
if (gate("presence", false)) {
|
||||
actions.add("set-presence");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
|
||||
@@ -218,6 +218,21 @@ export async function handleDiscordMessageAction(
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "set-presence") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "setPresence",
|
||||
accountId: accountId ?? undefined,
|
||||
status: readStringParam(params, "status"),
|
||||
activityType: readStringParam(params, "activityType"),
|
||||
activityName: readStringParam(params, "activityName"),
|
||||
activityUrl: readStringParam(params, "activityUrl"),
|
||||
activityState: readStringParam(params, "activityState"),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
const adminResult = await tryHandleDiscordMessageActionGuildAdmin({
|
||||
ctx,
|
||||
resolveChannelId,
|
||||
|
||||
@@ -48,6 +48,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
||||
"timeout",
|
||||
"kick",
|
||||
"ban",
|
||||
"set-presence",
|
||||
] as const;
|
||||
|
||||
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];
|
||||
|
||||
@@ -73,6 +73,8 @@ export type DiscordActionConfig = {
|
||||
emojiUploads?: boolean;
|
||||
stickerUploads?: boolean;
|
||||
channels?: boolean;
|
||||
/** Enable bot presence/activity changes (default: false). */
|
||||
presence?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordIntentsConfig = {
|
||||
|
||||
@@ -290,6 +290,7 @@ export const DiscordAccountSchema = z
|
||||
events: z.boolean().optional(),
|
||||
moderation: z.boolean().optional(),
|
||||
channels: z.boolean().optional(),
|
||||
presence: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
56
src/discord/monitor/gateway-registry.test.ts
Normal file
56
src/discord/monitor/gateway-registry.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearGateways,
|
||||
getGateway,
|
||||
registerGateway,
|
||||
unregisterGateway,
|
||||
} from "./gateway-registry.js";
|
||||
|
||||
function fakeGateway(props: Partial<GatewayPlugin> = {}): GatewayPlugin {
|
||||
return { isConnected: true, ...props } as unknown as GatewayPlugin;
|
||||
}
|
||||
|
||||
describe("gateway-registry", () => {
|
||||
beforeEach(() => {
|
||||
clearGateways();
|
||||
});
|
||||
|
||||
it("stores and retrieves a gateway by account", () => {
|
||||
const gateway = fakeGateway();
|
||||
registerGateway("account-a", gateway);
|
||||
expect(getGateway("account-a")).toBe(gateway);
|
||||
expect(getGateway("account-b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses collision-safe key when accountId is undefined", () => {
|
||||
const gateway = fakeGateway();
|
||||
registerGateway(undefined, gateway);
|
||||
expect(getGateway(undefined)).toBe(gateway);
|
||||
// "default" as a literal account ID must not collide with the sentinel key
|
||||
expect(getGateway("default")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unregisters a gateway", () => {
|
||||
const gateway = fakeGateway();
|
||||
registerGateway("account-a", gateway);
|
||||
unregisterGateway("account-a");
|
||||
expect(getGateway("account-a")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears all gateways", () => {
|
||||
registerGateway("a", fakeGateway());
|
||||
registerGateway("b", fakeGateway());
|
||||
clearGateways();
|
||||
expect(getGateway("a")).toBeUndefined();
|
||||
expect(getGateway("b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("overwrites existing entry for same account", () => {
|
||||
const gateway1 = fakeGateway({ isConnected: true });
|
||||
const gateway2 = fakeGateway({ isConnected: false });
|
||||
registerGateway("account-a", gateway1);
|
||||
registerGateway("account-a", gateway2);
|
||||
expect(getGateway("account-a")).toBe(gateway2);
|
||||
});
|
||||
});
|
||||
37
src/discord/monitor/gateway-registry.ts
Normal file
37
src/discord/monitor/gateway-registry.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
|
||||
/**
|
||||
* Module-level registry of active Discord GatewayPlugin instances.
|
||||
* Bridges the gap between agent tool handlers (which only have REST access)
|
||||
* and the gateway WebSocket (needed for operations like updatePresence).
|
||||
* Follows the same pattern as presence-cache.ts.
|
||||
*/
|
||||
const gatewayRegistry = new Map<string, GatewayPlugin>();
|
||||
|
||||
// Sentinel key for the default (unnamed) account. Uses a prefix that cannot
|
||||
// collide with user-configured account IDs.
|
||||
const DEFAULT_ACCOUNT_KEY = "\0__default__";
|
||||
|
||||
function resolveAccountKey(accountId?: string): string {
|
||||
return accountId ?? DEFAULT_ACCOUNT_KEY;
|
||||
}
|
||||
|
||||
/** Register a GatewayPlugin instance for an account. */
|
||||
export function registerGateway(accountId: string | undefined, gateway: GatewayPlugin): void {
|
||||
gatewayRegistry.set(resolveAccountKey(accountId), gateway);
|
||||
}
|
||||
|
||||
/** Unregister a GatewayPlugin instance for an account. */
|
||||
export function unregisterGateway(accountId?: string): void {
|
||||
gatewayRegistry.delete(resolveAccountKey(accountId));
|
||||
}
|
||||
|
||||
/** Get the GatewayPlugin for an account. Returns undefined if not registered. */
|
||||
export function getGateway(accountId?: string): GatewayPlugin | undefined {
|
||||
return gatewayRegistry.get(resolveAccountKey(accountId));
|
||||
}
|
||||
|
||||
/** Clear all registered gateways (for testing). */
|
||||
export function clearGateways(): void {
|
||||
gatewayRegistry.clear();
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordPresenceListener,
|
||||
@@ -591,6 +592,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (gateway) {
|
||||
registerGateway(account.accountId, gateway);
|
||||
}
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
emitter: gatewayEmitter,
|
||||
@@ -657,6 +661,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
unregisterGateway(account.accountId);
|
||||
stopGatewayLogging();
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
|
||||
@@ -53,6 +53,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
timeout: "none",
|
||||
kick: "none",
|
||||
ban: "none",
|
||||
"set-presence": "none",
|
||||
};
|
||||
|
||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||
|
||||
Reference in New Issue
Block a user