diff --git a/docs/channels/discord.md b/docs/channels/discord.md index d2198d2d55..dcabf1da76 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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..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>`. diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md index c4ae356b40..218de15b8e 100644 --- a/skills/discord/SKILL.md +++ b/skills/discord/SKILL.md @@ -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. diff --git a/src/agents/tools/discord-actions-presence.test.ts b/src/agents/tools/discord-actions-presence.test.ts new file mode 100644 index 0000000000..1a5d537310 --- /dev/null +++ b/src/agents/tools/discord-actions-presence.test.ts @@ -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 = (key) => key === "presence"; +const presenceDisabled: ActionGate = () => 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/, + ); + }); +}); diff --git a/src/agents/tools/discord-actions-presence.ts b/src/agents/tools/discord-actions-presence.ts new file mode 100644 index 0000000000..bdc7adf65f --- /dev/null +++ b/src/agents/tools/discord-actions-presence.ts @@ -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 = { + 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, + isActionEnabled: ActionGate, +): Promise> { + 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 } : {}), + })), + }); +} diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index ed4a3fc6cb..fa78d63a17 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -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, 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}`); } diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 359b075d2d..61c5b9a3ed 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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(), }; } diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index 5d33a62dfd..fedcb9eab2 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -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 }) => { diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index bccc7fac24..04ade812ea 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -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, diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 1884cacb0e..a98bdb0699 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -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]; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 283ab9e33e..ba427d4140 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -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 = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 838d9086d4..98bbc1b571 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), diff --git a/src/discord/monitor/gateway-registry.test.ts b/src/discord/monitor/gateway-registry.test.ts new file mode 100644 index 0000000000..876a47678d --- /dev/null +++ b/src/discord/monitor/gateway-registry.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/discord/monitor/gateway-registry.ts b/src/discord/monitor/gateway-registry.ts new file mode 100644 index 0000000000..54dfd6281b --- /dev/null +++ b/src/discord/monitor/gateway-registry.ts @@ -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(); + +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(); +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 8e907e0d9d..a61016a424 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -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("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); diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 12e5b0e714..d2cb9775a4 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -53,6 +53,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = {