@vetox-bot/discord-inviter
v1.0.0
Published
Invite tracking for discord.js v14: which invite a member joined with, with bounded memory, paced REST use and a clean shutdown.
Maintainers
Readme
@vetox-bot/discord-inviter
Invite tracking for discord.js v14: which invite, vanity URL or bot authorization every new member came through — with bounded memory, paced REST use and a clean shutdown.
Developed and maintained by Vetox for bots that run for weeks at scale: a vanity URL read never holds a guild's invite list while it waits on Discord's rate limiter, every request settles at a timeout, and memory stays flat through join storms.
Not affiliated with Discord.
Install
npm install @vetox-bot/discord-inviter discord.jsNode.js 22.12 or later and discord.js 14.16 or later.
The client needs the Guilds, GuildInvites and GuildMembers intents. The bot needs Manage Server in a guild to
read its invites and vanity URL, and View Audit Log to attribute bot joins. Guilds where it lacks them are skipped
without a request.
Quick start
import { Client, GatewayIntentBits } from 'discord.js';
import { InviteTracker } from '@vetox-bot/discord-inviter';
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildInvites, GatewayIntentBits.GuildMembers],
});
const tracker = new InviteTracker(client);
tracker.on('guildMemberAdd', (member, inviter, invite, error) => {
if (error) return console.error(error);
if (invite?.source === 'invite') console.log(`${member.user.tag} joined with ${invite.code} from ${inviter?.tag}`);
else if (invite?.isVanity) console.log(`${member.user.tag} joined through the vanity URL`);
});
tracker.on('error', (guild, error) => console.error(guild?.id, error));
await client.login(process.env.DISCORD_TOKEN);CommonJS works too: const { InviteTracker } = require('@vetox-bot/discord-inviter');.
Events
guildMemberAdd
(member, inviter, invite, error)
| Argument | Type | Meaning |
| --------- | ---------------------- | --------------------------------------------------------------------- |
| member | GuildMember | The member who joined. |
| inviter | User \| null | Who invited them, when known and cached. |
| invite | JoinInvite \| null | How they joined. Null only together with an error. |
| error | Error \| false | false on success. |
JoinInvite:
| Field | Type | Meaning |
| ----------- | ---------------- | ---------------------------------------------------------------------------- |
| source | 'invite' \| 'vanity' \| 'audit-log' \| 'unknown' | How the join was attributed. |
| code | string \| null | The invite or vanity code. |
| url | string \| null | https://discord.gg/<code>. |
| uses | number \| null | Uses of that invite after this join. |
| count | number \| null | Uses of every invite the inviter owns (vanity: the vanity URL's uses). |
| inviterId | string \| null | The inviter's id, also when the User is not cached. |
| isVanity | boolean | Joined through the vanity URL. |
A join is attributed to the first invite whose use count rose since the last snapshot, else to an invite the snapshot
did not have that now has exactly one use, else — when the guild has a vanity URL — to the vanity URL if its use count
rose. A bot is attributed to whoever authorized it, from the last five bot additions in the audit log. Anything else
is source: 'unknown'.
Until the warm-up has read a guild there is no snapshot to compare against, so only the single-use rule applies.
A vanity join is recognized only when a fresh vanity read can run for it — not while another read for that guild is in
flight or an earlier request is still unsettled, not within vanity.minIntervalMs of the last read or during backoff
after failures, and not while vanity.maxConcurrent reads are in flight. A rise in uses shows that someone joined
through the vanity URL, not who, so a join is credited only when no other join the tracker has seen could account for
it. Otherwise it is source: 'unknown' rather than a guess — the price of never queueing behind Discord's rate limit on
that endpoint.
error
(guild, error) — a request failed for a reason other than a timeout or shutdown, Discord returned a malformed
response, or a guildMemberAdd listener or an overridden handler threw or rejected. It is emitted only while someone
listens. An error listener that throws or rejects is ignored, so no listener can break the tracker or leave an
unhandled rejection.
Options
new InviteTracker(client, options?)
| Option | Default | Meaning |
| --------------------------- | ----------- | -------------------------------------------------------------------------------------- |
| trackMemberAdd | true | Listen for joins and emit guildMemberAdd. Turn off to attribute joins yourself. |
| trackVanity | true | Read vanity URLs to attribute vanity joins. |
| auditLogBots | true | Attribute bot joins from the audit log. |
| unknownInviter | 'none' | 'owner' credits unattributed joins to the guild owner. |
| warmup | {} | Fetch every readable guild once the client is ready; false to skip. |
| warmup.guildsPerBatch | 5 | Guilds fetched per batch. |
| warmup.batchIntervalMs | 1000 | Pause between batches. |
| requestTimeoutMs | 15000 | Upper bound on each invite-list, audit-log or guild-owner request. |
| vanity.minIntervalMs | 30000 | Minimum time between two vanity reads for one guild. |
| vanity.timeoutMs | 10000 | A vanity read is abandoned after this. |
| vanity.maxConcurrent | 2 | Vanity reads in flight at once, across all guilds. |
| vanity.maxBackoffMs | 3600000 | Longest wait after repeated vanity failures. The wait doubles from minIntervalMs, so there is none when that is 0. |
Invalid values throw at construction: TypeError for a wrong type, RangeError for a value out of range. Timeouts
and the batch interval are capped at 2147483647 ms, the longest timer Node.js keeps.
At the defaults the warm-up reads 5 guilds a second, so a bot in 10,000 guilds has every snapshot after about 35
minutes. Sharded bots share Discord's 50 requests/second global limit across every process: keep
guildsPerBatch × 1000 / batchIntervalMs × processes well below it.
API
InviteTracker
| Member | Description |
| ------------------------------------- | ----------------------------------------------------------------------------------------------- |
| invites: Map<string, TrackedInvite[]> | Per guild, the invites as of the last fetch: the baseline joins are compared against. |
| invitesVanity: Map<string, VanitySnapshot> | Per guild, the vanity URL as of the last successful read. |
| stats | { guilds, invites, vanityGuilds, pendingRefreshes, lockedGuilds, vanityReadsInFlight, vanityRequestsOutstanding }. |
| destroyed | True after destroy(). |
| withGuildLock(guildId, task) | Runs task after every earlier task for that guild settled. Not re-entrant; see below. |
| getInvites(guildId) | A guild's snapshot, or undefined. |
| fetchInvites(guild) | The guild's current invites under the tracker's bounds; null without Manage Server, on failure, or after requestTimeoutMs. |
| resolveInvites(guild, invites) | Stores invites (a Collection, Map or array) as the guild's snapshot, synchronously. |
| refresh(guild) | Re-reads a guild's invite list; concurrent calls are coalesced. |
| ready() | Runs the warm-up (also triggered by clientReady). |
| tempInvites(invite) | Converts one invite into a snapshot entry. |
| destroy() | Detaches from the client, stops waiting on in-flight requests, clears state. Idempotent. |
| static getMemberInvites(member) | { count, codes } of the invites a member owns. |
| static getTopInvites(guild) | Inviters ranked by total uses: [{ user, count, codes }]. |
| static getInfo(client, code) | Any invite's public data, or null. |
TrackedInvite holds code, uses, maxUses and inviterId; url and inviter are computed on read. It never
holds a discord.js object, so a snapshot cannot keep users or invites alive.
inviteTracker is exported as an alias of InviteTracker, and the event handlers (memberAdd, inviteUpdate,
guildCreate, guildDelete, ready) are overridable methods.
The static helpers are plain one-shot calls with no timeout of their own.
findUsedInvite(baseline, current)
The attribution rule as a pure function, for code that fetches invites itself.
Attributing joins yourself
Turn off the built-in handler and do the fetch, the comparison and the snapshot update inside withGuildLock, so
your writes and the tracker's refreshes never overwrite each other with an older list. fetchInvites keeps the lock
bounded: it skips guilds without Manage Server and gives up after requestTimeoutMs. The lock is not re-entrant:
inside a task, never await refresh(), ready() or memberAdd() for the same guild — each queues behind the task
that waits for it.
import { InviteTracker, findUsedInvite } from '@vetox-bot/discord-inviter';
const tracker = new InviteTracker(client, { trackMemberAdd: false, trackVanity: false });
client.on('guildMemberAdd', async member => {
try {
const used = await tracker.withGuildLock(member.guild.id, async () => {
const current = await tracker.fetchInvites(member.guild);
if (!current) return null;
const hit = findUsedInvite(tracker.getInvites(member.guild.id), current.values());
await tracker.resolveInvites(member.guild, current);
return hit;
});
// use `used` (an Invite, or null)
} catch (error) {
// discord.js re-emits a rejected listener as the client's `error` event, which ends the process when nothing
// listens to it
console.error(error);
}
});Memory and rate limits
- Nothing waits on Discord while holding an invite list. A snapshot is replaced synchronously; the fetched list is released before any other request.
- Vanity reads are bounded. Discord rate-limits the vanity endpoint hard, and @discordjs/rest waits out a 429 and
retries without limit. Here each read settles within
vanity.timeoutMswhatever the REST client does, reads are spaced and back off after failures, and at mostvanity.maxConcurrentare in flight at once. A request the REST client still holds after its read gave up blocks a new read of its own guild only — never the others — so there is at most one per guild;stats.vanityRequestsOutstandingcounts them. - One writer per guild at a time. Joins, refreshes and the warm-up of a guild are serialized, so an older list can never overwrite a newer one; invite events that arrive while a refresh waits share it.
- No request that must fail. Guilds without Manage Server are skipped instead of answering 403 — invalid requests count toward Discord's invalid-request limit.
- Every request is bounded. Each request the tracker makes settles within
requestTimeoutMs(vanity reads:vanity.timeoutMs) or atdestroy(), even while @discordjs/rest sleeps out a rate limit. Its REST requests leave the queue when they give up; the discord.js manager calls it makes — the invite fetch for a join, the audit log, the guild owner — take no signal, so discord.js finishes them in the background and the tracker discards the result. - Clean shutdown.
destroy()removes the tracker's client listeners and stops waiting on its requests; nothing it started records a snapshot or emits afterwards.stats.vanityRequestsOutstandingkeeps counting requests the REST client still holds, down to zero as it lets go of them. - No cache it did not ask for. Invites are read with
cache: falseor from the raw API, soguild.invites.cachestays as your own code leaves it. The invite fetch for a join adds each inviter toclient.users.cache, as every discord.js invite fetch does.
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md. Report security issues as described in SECURITY.md.
License
MIT © Vetox Inc.
