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:
committed by
GitHub
parent
97200984f8
commit
3e07bd8b48
@@ -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(accountId, 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();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("@buape/carbon", () => ({
|
||||
MessageCreateListener: class {},
|
||||
MessageReactionAddListener: class {},
|
||||
MessageReactionRemoveListener: class {},
|
||||
PresenceUpdateListener: class {},
|
||||
Row: class {
|
||||
constructor(_components: unknown[]) {}
|
||||
},
|
||||
|
||||
@@ -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,34 @@ 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;
|
||||
private accountId?: string;
|
||||
|
||||
constructor(params: { logger?: Logger; accountId?: string }) {
|
||||
super();
|
||||
this.logger = params.logger;
|
||||
this.accountId = params.accountId;
|
||||
}
|
||||
|
||||
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(
|
||||
this.accountId,
|
||||
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)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
src/discord/monitor/presence-cache.ts
Normal file
52
src/discord/monitor/presence-cache.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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, Map<string, GatewayPresenceUpdate>>();
|
||||
|
||||
function resolveAccountKey(accountId?: string): string {
|
||||
return accountId ?? "default";
|
||||
}
|
||||
|
||||
/** Update cached presence for a user. */
|
||||
export function setPresence(
|
||||
accountId: string | undefined,
|
||||
userId: string,
|
||||
data: GatewayPresenceUpdate,
|
||||
): void {
|
||||
const accountKey = resolveAccountKey(accountId);
|
||||
let accountCache = presenceCache.get(accountKey);
|
||||
if (!accountCache) {
|
||||
accountCache = new Map();
|
||||
presenceCache.set(accountKey, accountCache);
|
||||
}
|
||||
accountCache.set(userId, data);
|
||||
}
|
||||
|
||||
/** Get cached presence for a user. Returns undefined if not cached. */
|
||||
export function getPresence(
|
||||
accountId: string | undefined,
|
||||
userId: string,
|
||||
): GatewayPresenceUpdate | undefined {
|
||||
return presenceCache.get(resolveAccountKey(accountId))?.get(userId);
|
||||
}
|
||||
|
||||
/** Clear cached presence data. */
|
||||
export function clearPresences(accountId?: string): void {
|
||||
if (accountId) {
|
||||
presenceCache.delete(resolveAccountKey(accountId));
|
||||
return;
|
||||
}
|
||||
presenceCache.clear();
|
||||
}
|
||||
|
||||
/** Get the number of cached presence entries. */
|
||||
export function presenceCacheSize(): number {
|
||||
let total = 0;
|
||||
for (const accountCache of presenceCache.values()) {
|
||||
total += accountCache.size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -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,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}),
|
||||
);
|
||||
|
||||
if (discordCfg.intents?.presence) {
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordPresenceListener({ logger, accountId: account.accountId }),
|
||||
);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user