vercel / vercel/chat

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

Open
#912 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
2.4k
Forks
314
Avg merge
1d 18h
Merged PRs (30d)
63

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at the runGatewayListener raw handler and forwardGatewayEvent; the published failure is at dist/index.js:2650, where the event is serialized. Reproduce with an unregistered guild channel, then verify that its message forwards and registers, while DMs and already-registered channels retain their existing behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, typescript
Domain
backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
64/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.