Skip to content

Discord adapter drops guild messages from unregistered channels: await before forwarding lets discord.js make the raw packet circular #912

Description

@BuckG71

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

  1. Run the adapter in gateway-forwarding mode with a non-empty
    respondToChannelIds that does not include some channel C.
  2. Post a message in C from a non-bot account.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions