Skip to content

Commit 794a587

Browse files
kfiramarKfir
andauthored
feat: conditional Gateway intents for MESSAGE_CONTENT (#24)
The Gateway identify payload always requested MESSAGE_CONTENT, which caused Discord to close the session with code 4014 when the app did not have that privileged intent enabled. This change computes intents from the enabled runtime features so slash-command-only deployments stay connected while message-based features still opt into the broader subscription set. Constraint: Discord rejects Gateway sessions that request unauthorized privileged intents Rejected: Require Message Content Intent for every install | breaks slash-command-only setups Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep message-content intent conditional on features that actually consume message bodies Tested: npm test; npm run typecheck Not-tested: Live Discord Gateway handshake on a fresh non-privileged bot after merge Co-authored-by: Kfir <suukpehoy@gmail.com>
1 parent 3a5e50a commit 794a587

3 files changed

Lines changed: 81 additions & 2 deletions

File tree

src/gateway.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const GATEWAY_ENCODING = "json";
66
const MAX_CONSECUTIVE_FAILURES = 5;
77
const MAX_BACKOFF_MS = 60_000;
88
const DEFAULT_RECONNECT_MS = 5000;
9+
const GUILD_INTENT = 1;
10+
const GUILD_MESSAGES_INTENT = 512;
11+
const MESSAGE_CONTENT_INTENT = 32768;
912

1013
interface GatewayPayload {
1114
op: number;
@@ -44,6 +47,11 @@ export interface MessageCreateEvent {
4447
type InteractionHandler = (interaction: InteractionCreateEvent) => Promise<unknown>;
4548
type MessageHandler = (message: MessageCreateEvent) => Promise<void>;
4649

50+
export interface GatewayOptions {
51+
listenForMessages?: boolean;
52+
includeMessageContent?: boolean;
53+
}
54+
4755
export async function respondViaCallback(
4856
ctx: PluginContext,
4957
interactionId: string,
@@ -81,6 +89,7 @@ export async function connectGateway(
8189
token: string,
8290
onInteraction: InteractionHandler,
8391
onMessage?: MessageHandler,
92+
options: GatewayOptions = {},
8493
): Promise<{ close: () => void }> {
8594
if (typeof WebSocket === "undefined") {
8695
ctx.logger.warn(
@@ -105,6 +114,12 @@ export async function connectGateway(
105114
let closed = false;
106115
let consecutiveFailures = 0;
107116
let lastHeartbeatIntervalMs = 41250;
117+
const listenForMessages = options.listenForMessages ?? Boolean(onMessage);
118+
const includeMessageContent = options.includeMessageContent ?? listenForMessages;
119+
const intents =
120+
GUILD_INTENT |
121+
(listenForMessages ? GUILD_MESSAGES_INTENT : 0) |
122+
(includeMessageContent ? MESSAGE_CONTENT_INTENT : 0);
108123

109124
function getReconnectDelay(): number {
110125
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
@@ -148,7 +163,7 @@ export async function connectGateway(
148163
op: 2,
149164
d: {
150165
token: `Bot ${token}`,
151-
intents: 1 | 512 | 32768, // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT
166+
intents,
152167
properties: {
153168
os: "linux",
154169
browser: "paperclip-plugin-discord",

src/worker.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,14 +256,25 @@ const plugin = definePlugin({
256256
}
257257
}
258258

259+
const gatewayNeedsMessages =
260+
config.enableInbound !== false ||
261+
config.enableMediaPipeline === true ||
262+
config.enableCustomCommands === true ||
263+
config.enableProactiveSuggestions === true ||
264+
config.enableIntelligence === true;
265+
259266
// --- Gateway connection for real-time interaction handling ---
260267
const gateway = await connectGateway(
261268
ctx,
262269
token,
263270
async (interaction) => {
264271
return handleInteraction(ctx, interaction as any, cmdCtx);
265272
},
266-
handleMessageCreate,
273+
gatewayNeedsMessages ? handleMessageCreate : undefined,
274+
{
275+
listenForMessages: gatewayNeedsMessages,
276+
includeMessageContent: gatewayNeedsMessages,
277+
},
267278
);
268279

269280
ctx.events.on("plugin.stopping", async () => {

tests/gateway.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,59 @@ describe("connectGateway", () => {
4545
expect(handler).not.toHaveBeenCalled();
4646
result.close(); // should not throw
4747
});
48+
49+
it("uses guild-only intents when message subscriptions are disabled", async () => {
50+
class FakeWebSocket {
51+
static instances: FakeWebSocket[] = [];
52+
onopen: (() => void) | null = null;
53+
onmessage: ((event: { data: string }) => void) | null = null;
54+
onclose: ((event: { code: number; reason: string }) => void) | null = null;
55+
onerror: (() => void) | null = null;
56+
sent: string[] = [];
57+
58+
constructor(_url: string) {
59+
FakeWebSocket.instances.push(this);
60+
}
61+
62+
send(payload: string) {
63+
this.sent.push(payload);
64+
}
65+
66+
close() {}
67+
}
68+
69+
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
70+
71+
const { connectGateway } = await import("../src/gateway.js");
72+
const ctx = makeCtx();
73+
(ctx.http.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
74+
ok: true,
75+
json: async () => ({ url: "wss://gateway.discord.test" }),
76+
});
77+
78+
const result = await connectGateway(ctx, "fake-token", vi.fn(), undefined, {
79+
listenForMessages: false,
80+
includeMessageContent: false,
81+
});
82+
83+
const socket = FakeWebSocket.instances[0];
84+
expect(socket).toBeDefined();
85+
86+
socket.onmessage?.({
87+
data: JSON.stringify({
88+
op: 10,
89+
d: { heartbeat_interval: 10000 },
90+
s: null,
91+
t: null,
92+
}),
93+
});
94+
95+
const identify = JSON.parse(socket.sent[0] ?? "{}");
96+
expect(identify.op).toBe(2);
97+
expect(identify.d.intents).toBe(1);
98+
99+
result.close();
100+
});
48101
});
49102

50103
describe("respondViaCallback", () => {

0 commit comments

Comments
 (0)