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:
Michelle Tilley
2026-02-03 14:00:11 -08:00
committed by clawdinator[bot]
parent b64c1a56a1
commit 5af322f710
15 changed files with 564 additions and 2 deletions

View File

@@ -100,7 +100,7 @@ In **Bot** → **Privileged Gateway Intents**, enable:
- **Message Content Intent** (required to read message text in most guilds; without it youll see “Used disallowed intents” or the bot will connect but not react to messages) - **Message Content Intent** (required to read message text in most guilds; without it youll 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) - **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) ### 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, voiceStatus: true,
events: true, events: true,
moderation: false, moderation: false,
presence: false,
}, },
replyToMode: "off", replyToMode: "off",
dm: { dm: {
@@ -353,6 +354,7 @@ ack reaction after the bot replies.
- `channels` (create/edit/delete channels + categories + permissions) - `channels` (create/edit/delete channels + categories + permissions)
- `roles` (role add/remove, default `false`) - `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, 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`. - `execApprovals`: Discord-only exec approval DMs (button UI). Supports `enabled`, `approvers`, `agentFilter`, `sessionFilter`.
Reaction notifications use `guilds.<id>.reactionNotifications`: Reaction notifications use `guilds.<id>.reactionNotifications`:
@@ -412,6 +414,7 @@ Allowlist notes (PK-enabled):
| events | enabled | List/create scheduled events | | events | enabled | List/create scheduled events |
| roles | disabled | Role add/remove | | roles | disabled | Role add/remove |
| moderation | disabled | Timeout/kick/ban | | 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. - `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` - `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate` - `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
- `timeout`, `kick`, `ban` - `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. 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>`. Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.

View File

@@ -1,6 +1,6 @@
--- ---
name: discord 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"]}}} metadata: {"openclaw":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
--- ---
@@ -139,6 +139,7 @@ Use `discord.actions.*` to disable action groups:
- `roles` (role add/remove, default `false`) - `roles` (role add/remove, default `false`)
- `channels` (channel/category create/edit/delete/move, default `false`) - `channels` (channel/category create/edit/delete/move, default `false`)
- `moderation` (timeout/kick/ban, default `false`) - `moderation` (timeout/kick/ban, default `false`)
- `presence` (bot status/activity, default `false`)
### Read recent messages ### 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 ## Discord Writing Style Guide
**Keep it conversational!** Discord is a chat platform, not documentation. **Keep it conversational!** Discord is a chat platform, not documentation.

View 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/,
);
});
});

View 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 } : {}),
})),
});
}

View File

@@ -4,6 +4,7 @@ import { createActionGate, readStringParam } from "./common.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js"; import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
const messagingActions = new Set([ const messagingActions = new Set([
"react", "react",
@@ -51,6 +52,8 @@ const guildActions = new Set([
const moderationActions = new Set(["timeout", "kick", "ban"]); const moderationActions = new Set(["timeout", "kick", "ban"]);
const presenceActions = new Set(["setPresence"]);
export async function handleDiscordAction( export async function handleDiscordAction(
params: Record<string, unknown>, params: Record<string, unknown>,
cfg: OpenClawConfig, cfg: OpenClawConfig,
@@ -67,5 +70,8 @@ export async function handleDiscordAction(
if (moderationActions.has(action)) { if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled); return await handleDiscordModerationAction(action, params, isActionEnabled);
} }
if (presenceActions.has(action)) {
return await handleDiscordPresenceAction(action, params, isActionEnabled);
}
throw new Error(`Unknown action: ${action}`); throw new Error(`Unknown action: ${action}`);
} }

View File

@@ -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() { function buildChannelManagementSchema() {
return { return {
name: Type.Optional(Type.String()), name: Type.Optional(Type.String()),
@@ -225,6 +255,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
...buildModerationSchema(), ...buildModerationSchema(),
...buildGatewaySchema(), ...buildGatewaySchema(),
...buildChannelManagementSchema(), ...buildChannelManagementSchema(),
...buildPresenceSchema(),
}; };
} }

View File

@@ -88,6 +88,9 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
actions.add("kick"); actions.add("kick");
actions.add("ban"); actions.add("ban");
} }
if (gate("presence", false)) {
actions.add("set-presence");
}
return Array.from(actions); return Array.from(actions);
}, },
extractToolSend: ({ args }) => { extractToolSend: ({ args }) => {

View File

@@ -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({ const adminResult = await tryHandleDiscordMessageActionGuildAdmin({
ctx, ctx,
resolveChannelId, resolveChannelId,

View File

@@ -48,6 +48,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"timeout", "timeout",
"kick", "kick",
"ban", "ban",
"set-presence",
] as const; ] as const;
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number]; export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];

View File

@@ -73,6 +73,8 @@ export type DiscordActionConfig = {
emojiUploads?: boolean; emojiUploads?: boolean;
stickerUploads?: boolean; stickerUploads?: boolean;
channels?: boolean; channels?: boolean;
/** Enable bot presence/activity changes (default: false). */
presence?: boolean;
}; };
export type DiscordIntentsConfig = { export type DiscordIntentsConfig = {

View File

@@ -290,6 +290,7 @@ export const DiscordAccountSchema = z
events: z.boolean().optional(), events: z.boolean().optional(),
moderation: z.boolean().optional(), moderation: z.boolean().optional(),
channels: z.boolean().optional(), channels: z.boolean().optional(),
presence: z.boolean().optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@@ -0,0 +1,55 @@
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 'default' key when accountId is undefined", () => {
const gateway = fakeGateway();
registerGateway(undefined, gateway);
expect(getGateway(undefined)).toBe(gateway);
expect(getGateway("default")).toBe(gateway);
});
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);
});
});

View File

@@ -0,0 +1,33 @@
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>();
function resolveAccountKey(accountId?: string): string {
return accountId ?? "default";
}
/** 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();
}

View File

@@ -27,6 +27,7 @@ import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js"; import { resolveDiscordUserAllowlist } from "../resolve-users.js";
import { normalizeDiscordToken } from "../token.js"; import { normalizeDiscordToken } from "../token.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js"; import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
import { import {
DiscordMessageListener, DiscordMessageListener,
DiscordPresenceListener, DiscordPresenceListener,
@@ -591,6 +592,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
} }
const gateway = client.getPlugin<GatewayPlugin>("gateway"); const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (gateway) {
registerGateway(account.accountId, gateway);
}
const gatewayEmitter = getDiscordGatewayEmitter(gateway); const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({ const stopGatewayLogging = attachDiscordGatewayLogging({
emitter: gatewayEmitter, emitter: gatewayEmitter,
@@ -657,6 +661,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}, },
}); });
} finally { } finally {
unregisterGateway(account.accountId);
stopGatewayLogging(); stopGatewayLogging();
if (helloTimeoutId) { if (helloTimeoutId) {
clearTimeout(helloTimeoutId); clearTimeout(helloTimeoutId);

View File

@@ -53,6 +53,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
timeout: "none", timeout: "none",
kick: "none", kick: "none",
ban: "none", ban: "none",
"set-presence": "none",
}; };
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = { const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {