Summary
In @chat-adapter/discord@4.38.1, a guild MESSAGE_CREATE arriving from a
channel that is not already in respondToChannelIds is silently discarded.
forwardGatewayEvent calls JSON.stringify(event) with no replacer, and by the
time it runs the raw packet has become circular, so the stringify throws, the
catch logs, and the event never reaches the webhook.
The failure is specific and unfortunate: the only code path that can discover a
new channel is the one that breaks. Channels already registered keep working, so
an existing deployment looks healthy while onboarding any new channel is
impossible. Nothing surfaces to the operator except a log line.
This is a regression from 4.29.0, which forwards the same packet without
incident.
Root cause
runGatewayListener registers a raw handler. In 4.29.0 it forwards
immediately:
client.on("raw", async (packet) => {
if (isShuttingDown) return;
if (!packet.t) return;
this.logger.info("Discord Gateway forwarding event", { type: packet.t });
await this.forwardGatewayEvent(webhookUrl, {
type: `GATEWAY_${packet.t}`,
timestamp: Date.now(),
data: packet.d,
});
});
The handler runs synchronously up to the fetch inside forwardGatewayEvent, so
JSON.stringify sees the packet exactly as it came off the wire.
4.38.1 inserts a thread-detection block before the forward:
let data = packet.d;
if (packet.t === "MESSAGE_CREATE" && this.respondToChannelIds.length > 0) {
const message = packet.d;
if (!(message.author.bot || this.respondToChannelIds.includes(message.channel_id))) {
const channel = await client.channels.fetch(message.channel_id).catch(...);
// ^^^^^ yields the event loop
if (channel?.isThread() && ...) { data = { ...message, thread: {...} }; }
}
}
await this.forwardGatewayEvent(webhookUrl, { type: `GATEWAY_${packet.t}`, timestamp: Date.now(), data });
await client.channels.fetch(...) yields control. discord.js's own
MESSAGE_CREATE action handler then runs against the same object and, while
constructing the GuildMember, sets packet.d.member.user. That back-reference
makes the packet circular. When forwardGatewayEvent finally serializes it:
body: JSON.stringify(event) // throws
[chat-sdk:discord] Error forwarding Gateway event {
type: 'GATEWAY_MESSAGE_CREATE',
error: 'TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'member' -> object with constructor 'Object'
--- property 'user' closes the circle'
}
The guard conditions explain the observed blast radius exactly:
- DMs carry no
member, so they never become circular. Unaffected.
- Registered guild channels short-circuit on
respondToChannelIds.includes(message.channel_id), skip the await, and
forward before discord.js can mutate anything. Unaffected.
- Unregistered guild channels take the
await. Always dropped.
Reproduction
- Run the adapter in gateway-forwarding mode with a non-empty
respondToChannelIds that does not include some channel C.
- Post a message in C from a non-bot account.
- Observe
Discord Gateway forwarding event { type: 'MESSAGE_CREATE' } with no
corresponding forwarded Gateway event received, and the circular-structure
error above.
Impact
Any deployment that registers channels by observing a first message cannot
onboard a new channel at all. In our case a newly created Discord channel was
invisible to the router across two attempts, with no error anywhere the operator
would look — only in the adapter's own error log.
Suggested fixes
Either would do; the second is closer to the pre-4.38.1 contract.
1. Serialize cycle-safely. Minimal, and because the mutation discord.js adds
is exactly member.user (which duplicates author, and which Discord does not
send on the wire), dropping the cycle restores the original payload shape:
const ancestors = [];
const body = JSON.stringify(event, function (key, value) {
if (typeof value !== "object" || value === null) return value;
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop();
if (ancestors.includes(value)) return void 0;
ancestors.push(value);
return value;
});
Note this must track the ancestor path, not a WeakSet of everything seen — a
WeakSet also drops legitimate repeated references, e.g. a self-mention where
mentions[0] and author are the same object.
2. Snapshot the packet before yielding. Take a structured clone of packet.d
at the top of the raw handler and use that throughout, so the forwarded payload
is the wire packet regardless of what discord.js does to its copy afterwards.
Environment
@chat-adapter/discord 4.38.1 (regression from 4.29.0)
- Still present in 4.40.0, latest as of 2026-09-05 — verified against the
published tarball: forwardGatewayEvent is unchanged and
body: JSON.stringify(event) is still unguarded at dist/index.js:2650.
chat 4.38.1
- Node 24.14.0, macOS 26 arm64
- Gateway-forwarding mode with a webhook URL
Workaround
Patched locally via pnpm patch using fix 1 above. Verified: guild messages from
an unregistered channel now forward and the channel registers correctly, with no
change in behaviour for DMs or already-registered channels.
Summary
In
@chat-adapter/discord@4.38.1, a guildMESSAGE_CREATEarriving from achannel that is not already in
respondToChannelIdsis silently discarded.forwardGatewayEventcallsJSON.stringify(event)with no replacer, and by thetime it runs the raw packet has become circular, so the stringify throws, the
catch logs, and the event never reaches the webhook.
The failure is specific and unfortunate: the only code path that can discover a
new channel is the one that breaks. Channels already registered keep working, so
an existing deployment looks healthy while onboarding any new channel is
impossible. Nothing surfaces to the operator except a log line.
This is a regression from
4.29.0, which forwards the same packet withoutincident.
Root cause
runGatewayListenerregisters arawhandler. In 4.29.0 it forwardsimmediately:
The handler runs synchronously up to the
fetchinsideforwardGatewayEvent, soJSON.stringifysees the packet exactly as it came off the wire.4.38.1 inserts a thread-detection block before the forward:
await client.channels.fetch(...)yields control. discord.js's ownMESSAGE_CREATEaction handler then runs against the same object and, whileconstructing the
GuildMember, setspacket.d.member.user. That back-referencemakes the packet circular. When
forwardGatewayEventfinally serializes it:The guard conditions explain the observed blast radius exactly:
member, so they never become circular. Unaffected.respondToChannelIds.includes(message.channel_id), skip theawait, andforward before discord.js can mutate anything. Unaffected.
await. Always dropped.Reproduction
respondToChannelIdsthat does not include some channel C.Discord Gateway forwarding event { type: 'MESSAGE_CREATE' }with nocorresponding
forwarded Gateway event received, and the circular-structureerror above.
Impact
Any deployment that registers channels by observing a first message cannot
onboard a new channel at all. In our case a newly created Discord channel was
invisible to the router across two attempts, with no error anywhere the operator
would look — only in the adapter's own error log.
Suggested fixes
Either would do; the second is closer to the pre-4.38.1 contract.
1. Serialize cycle-safely. Minimal, and because the mutation discord.js adds
is exactly
member.user(which duplicatesauthor, and which Discord does notsend on the wire), dropping the cycle restores the original payload shape:
Note this must track the ancestor path, not a
WeakSetof everything seen — aWeakSetalso drops legitimate repeated references, e.g. a self-mention wherementions[0]andauthorare the same object.2. Snapshot the packet before yielding. Take a structured clone of
packet.dat the top of the
rawhandler and use that throughout, so the forwarded payloadis the wire packet regardless of what discord.js does to its copy afterwards.
Environment
@chat-adapter/discord4.38.1 (regression from 4.29.0)published tarball:
forwardGatewayEventis unchanged andbody: JSON.stringify(event)is still unguarded atdist/index.js:2650.chat4.38.1Workaround
Patched locally via
pnpm patchusing fix 1 above. Verified: guild messages froman unregistered channel now forward and the channel registers correctly, with no
change in behaviour for DMs or already-registered channels.