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
This commit is contained in:
parent
8b68cdd9bc
commit
e5a5e6ad56
@ -1,5 +1,6 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { getPresence } from "../../discord/monitor/presence-cache.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
createChannelDiscord,
|
||||
@ -54,7 +55,10 @@ export async function handleDiscordGuildAction(
|
||||
const member = accountId
|
||||
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||
: await fetchMemberInfoDiscord(guildId, userId);
|
||||
return jsonResult({ ok: true, member });
|
||||
const presence = getPresence(userId);
|
||||
const activities = presence?.activities ?? undefined;
|
||||
const status = presence?.status ?? undefined;
|
||||
return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) });
|
||||
}
|
||||
case "roleInfo": {
|
||||
if (!isActionEnabled("roleInfo")) {
|
||||
|
||||
@ -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=["*"].',
|
||||
};
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -256,6 +256,13 @@ export const DiscordAccountSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
intents: z
|
||||
.object({
|
||||
presence: z.boolean().optional(),
|
||||
guildMembers: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@ -4,11 +4,13 @@ import {
|
||||
MessageCreateListener,
|
||||
MessageReactionAddListener,
|
||||
MessageReactionRemoveListener,
|
||||
PresenceUpdateListener,
|
||||
} from "@buape/carbon";
|
||||
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationSeconds } from "../../infra/format-duration.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { setPresence } from "./presence-cache.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
@ -269,3 +271,28 @@ async function handleDiscordReactionEvent(params: {
|
||||
params.logger.error(danger(`discord reaction handler failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
type PresenceUpdateEvent = Parameters<PresenceUpdateListener["handle"]>[0];
|
||||
|
||||
export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
private logger?: Logger;
|
||||
|
||||
constructor(logger?: Logger) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async handle(data: PresenceUpdateEvent) {
|
||||
try {
|
||||
const userId =
|
||||
"user" in data && data.user && typeof data.user === "object" && "id" in data.user
|
||||
? String(data.user.id)
|
||||
: undefined;
|
||||
if (!userId) return;
|
||||
setPresence(userId, data as import("discord-api-types/v10").GatewayPresenceUpdate);
|
||||
} catch (err) {
|
||||
const logger = this.logger ?? discordEventQueueLog;
|
||||
logger.error(danger(`discord presence handler failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
src/discord/monitor/presence-cache.ts
Normal file
27
src/discord/monitor/presence-cache.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
|
||||
/**
|
||||
* In-memory cache of Discord user presence data.
|
||||
* Populated by PRESENCE_UPDATE gateway events when the GuildPresences intent is enabled.
|
||||
*/
|
||||
const presenceCache = new Map<string, GatewayPresenceUpdate>();
|
||||
|
||||
/** Update cached presence for a user. */
|
||||
export function setPresence(userId: string, data: GatewayPresenceUpdate): void {
|
||||
presenceCache.set(userId, data);
|
||||
}
|
||||
|
||||
/** Get cached presence for a user. Returns undefined if not cached. */
|
||||
export function getPresence(userId: string): GatewayPresenceUpdate | undefined {
|
||||
return presenceCache.get(userId);
|
||||
}
|
||||
|
||||
/** Clear all cached presence data. */
|
||||
export function clearPresences(): void {
|
||||
presenceCache.clear();
|
||||
}
|
||||
|
||||
/** Get the number of cached presence entries. */
|
||||
export function presenceCacheSize(): number {
|
||||
return presenceCache.size;
|
||||
}
|
||||
@ -28,6 +28,7 @@ import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordPresenceListener,
|
||||
DiscordReactionListener,
|
||||
DiscordReactionRemoveListener,
|
||||
registerDiscordListener,
|
||||
@ -109,6 +110,25 @@ function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
return details.length > 0 ? ` (${details.join(", ")})` : "";
|
||||
}
|
||||
|
||||
function resolveDiscordGatewayIntents(
|
||||
intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig,
|
||||
): number {
|
||||
let intents =
|
||||
GatewayIntents.Guilds |
|
||||
GatewayIntents.GuildMessages |
|
||||
GatewayIntents.MessageContent |
|
||||
GatewayIntents.DirectMessages |
|
||||
GatewayIntents.GuildMessageReactions |
|
||||
GatewayIntents.DirectMessageReactions;
|
||||
if (intentsConfig?.presence) {
|
||||
intents |= GatewayIntents.GuildPresences;
|
||||
}
|
||||
if (intentsConfig?.guildMembers) {
|
||||
intents |= GatewayIntents.GuildMembers;
|
||||
}
|
||||
return intents;
|
||||
}
|
||||
|
||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveDiscordAccount({
|
||||
@ -451,13 +471,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
reconnect: {
|
||||
maxAttempts: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
intents:
|
||||
GatewayIntents.Guilds |
|
||||
GatewayIntents.GuildMessages |
|
||||
GatewayIntents.MessageContent |
|
||||
GatewayIntents.DirectMessages |
|
||||
GatewayIntents.GuildMessageReactions |
|
||||
GatewayIntents.DirectMessageReactions,
|
||||
intents: resolveDiscordGatewayIntents(discordCfg.intents),
|
||||
autoInteractions: true,
|
||||
}),
|
||||
],
|
||||
@ -527,6 +541,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}),
|
||||
);
|
||||
|
||||
if (discordCfg.intents?.presence) {
|
||||
registerDiscordListener(client.listeners, new DiscordPresenceListener(logger));
|
||||
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
|
||||
}
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
// Start exec approvals handler after client is ready
|
||||
|
||||
Loading…
Reference in New Issue
Block a user