feat(discord): add configurable privileged Gateway Intents (GuildPresences, GuildMembers) (#2266)

* feat(discord): add configurable privileged Gateway Intents (GuildPresences, GuildMembers)

Add support for optionally enabling Discord privileged Gateway Intents
via config, starting with GuildPresences and GuildMembers.

When `channels.discord.intents.presence` is set to true:
- GatewayIntents.GuildPresences is added to the gateway connection
- A PresenceUpdateListener caches user presence data in memory
- The member-info action includes user status and activities
  (e.g. Spotify listening activity) from the cache

This enables use cases like:
- Seeing what music a user is currently listening to
- Checking user online/offline/idle/dnd status
- Tracking user activities through the bot API

Both intents require Portal opt-in (Discord Developer Portal →
Privileged Gateway Intents) before they can be used.

Changes:
- config: add `channels.discord.intents.{presence,guildMembers}`
- provider: compute intents dynamically from config
- listeners: add DiscordPresenceListener (extends PresenceUpdateListener)
- presence-cache: simple in-memory Map<userId, GatewayPresenceUpdate>
- discord-actions-guild: include cached presence in member-info response
- schema: add labels and descriptions for new config fields

* fix(test): add PresenceUpdateListener to @buape/carbon mock

* Discord: scope presence cache by account

---------

Co-authored-by: kugutsushi <kugutsushi@clawd>
Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Kentaro Kuribayashi
2026-01-27 01:39:54 +09:00
committed by GitHub
parent 97200984f8
commit 3e07bd8b48
8 changed files with 142 additions and 8 deletions

View File

@@ -321,6 +321,8 @@ const FIELD_LABELS: Record<string, string> = {
"channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
"channels.discord.retry.jitter": "Discord Retry Jitter",
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
"channels.discord.intents.presence": "Discord Presence Intent",
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
"channels.slack.dm.policy": "Slack DM Policy",
"channels.slack.allowBots": "Slack Allow Bot Messages",
"channels.discord.token": "Discord Bot Token",
@@ -657,6 +659,10 @@ const FIELD_HELP: Record<string, string> = {
"channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.",
"channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.",
"channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).",
"channels.discord.intents.presence":
"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
"channels.discord.intents.guildMembers":
"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
"channels.slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
};

View File

@@ -72,6 +72,13 @@ export type DiscordActionConfig = {
channels?: boolean;
};
export type DiscordIntentsConfig = {
/** Enable Guild Presences privileged intent (requires Portal opt-in). Default: false. */
presence?: boolean;
/** Enable Guild Members privileged intent (requires Portal opt-in). Default: false. */
guildMembers?: boolean;
};
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
@@ -139,6 +146,8 @@ export type DiscordAccountConfig = {
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Exec approval forwarding configuration. */
execApprovals?: DiscordExecApprovalConfig;
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
intents?: DiscordIntentsConfig;
};
export type DiscordConfig = {

View File

@@ -256,6 +256,13 @@ export const DiscordAccountSchema = z
})
.strict()
.optional(),
intents: z
.object({
presence: z.boolean().optional(),
guildMembers: z.boolean().optional(),
})
.strict()
.optional(),
})
.strict();