npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

privage.js

v0.27.1

Published

Official SDK for building Privage bots.

Readme

privage.js

The official SDK for building Privage bots. It wraps the bot gateway so you write simple, familiar event handlers instead of raw WebSocket + opcodes.

Full documentation: privage.xyz/docs

Feature-complete: messages (incl. threads), embeds and file attachments, buttons, select menus, slash commands, modals, ephemeral replies, polls, reactions, members/roles/moderation, presence, webhooks, collectors, a self-maintaining name cache, and auto reconnect + resume — all typed. Bot DMs are the one remaining backend-gated feature.

Install

npm install privage.js

Quick start

import { Client, Intents } from 'privage.js';

const client = new Client({ intents: [Intents.Messages] });

client.on('ready', ({ user }) => console.log(`Logged in as ${user.username}`));

client.on('messageCreate', async (msg) => {
  if (msg.author.bot) return;              // loop guard
  if (msg.content === '!ping') await msg.reply('pong');
});

client.login(process.env.PRIVAGE_BOT_TOKEN);

Create a bot in the Privage app (User Settings → Bots) and copy the token — it's shown once, at creation. Add the bot to a server from the same panel (needs Manage Server in the target). Full walkthrough: Create a bot.

Configuration

The client connects to Privage production by default — no URLs needed. Overrides are for self-hosted or local-dev backends:

new Client({
  intents: [Intents.Messages],
  api: 'http://localhost:3001',          // default: $PRIVAGE_API or https://api.privage.xyz
  ws: 'ws://localhost:4000/socket',      // default: $PRIVAGE_WS, derived from a custom `api`, or wss://ws.privage.xyz/socket
  debug: false,                          // emit raw wire frames on the `debug` event
});

Intents

Declare only what you handle. Omitting intents entirely means you receive everything — the SDK warns when you do.

| Intent | Delivers | |--------|----------| | Intents.Messages | messages (including in-thread replies), edits, deletes, pins, polls, tip jars | | Intents.Reactions | reaction updates | | Intents.Typing | typing start/stop | | Intents.Members | member joins/leaves/updates, roles, nicknames | | Intents.Moderation | bans, kicks, timeouts | | Intents.Servers | channel/role/emoji/config structure changes | | Intents.Interactions | button clicks, select menus, slash commands, modal submits — delivered bot-direct |

Events

| Event | Payload | |-------|---------| | ready | { user } — fired once, after the first connect | | gatewayEvent | { name, payload, eventId } — every canonical bot.v1 event; useful for newly-added events without a convenience wrapper | | messageCreate | Message | | messageUpdate | Message | | messageDelete | { id, channelId, serverId } | | messageDeleteBulk | { ids, channelId, serverId } | | reactionUpdate | { messageId, channelId, reactions } — full snapshot | | reactionAdd | { messageId, channelId, emoji, userId } — a specific user reacted | | reactionRemove | { messageId, channelId, emoji, userId } | | memberJoin | Member | | memberLeave | { userId, serverId } | | roleAdd / roleRemove | { serverId, userId, roleId } | | memberUpdate | { serverId, userId, displayName?, avatarUrl?, nickname? } | | memberBan / memberUnban | { serverId, userId, username, displayName } | | memberTimeout | { serverId, userId, until, moderatorId, reason } | | memberUntimeout | { serverId, userId } | | messagePin / messageUnpin | { messageId, channelId } | | typingStart / typingStop | { userId, channelId } | | channelCreate / channelUpdate | Channel | | channelDelete | { channelId, serverId } | | roleCreate / roleUpdate | Role | | roleDelete | { roleId, serverId } | | pollVote | { pollId, messageId, voterId, optionIdx, totalVotes } | | pollClose | { pollId, messageId, totalVotes } | | interaction | Interaction — button click, select choice, slash command, or modal submit (discriminate with .kind) | | connectionError | PrivageConnectionError — recoverable; the SDK is reconnecting | | warn | string | | debug | string (only when debug: true) |

Reconnect & resume (automatic)

You don't handle any of this. On a dropped connection the client reconnects with backoff, then resumes the event stream (replaying anything buffered while you were away) — all deduplicated. Your handlers just keep firing. If the gap was too large for the server's replay buffer, the session is reset instead and you get a one-off warn ("events during the gap may be lost") suggesting a REST re-fetch if your bot keys off historical state.

Sending

await msg.reply('pong');                 // reply (threads via reply_to)
msg.reference;                           // reply/forward/pin pointer (+preview), null otherwise
msg.replyToId;                           // compat alias: reference.messageId for replies
await msg.channel.send('hi');            // plain send
await msg.channel.send({ content: 'rich', embeds: [ /* ... */ ] });  // embeds are bot-only

await client.send(channelId, 'hi');      // send to any channel by id — no server id needed
await client.channels.get(channelId)?.send('hi');  // same, via the cache

In-thread replies arrive as normal messageCreate events (with the thread as channelId), so a bot sees thread messages without any extra setup.

Embeds & attachments

Build rich embeds with EmbedBuilder — pass builders straight into embeds, no .toJSON() call needed — and upload files with AttachmentBuilder (a path, Buffer, or Blob):

import { EmbedBuilder, AttachmentBuilder } from 'privage.js';

await msg.channel.send({
  embeds: [
    new EmbedBuilder()
      .setTitle('Deploy finished')
      .setColor(0x35b9e8)                    // or '#35b9e8'
      .addFields({ name: 'Duration', value: '42s', inline: true })
      .setTimestamp(),
  ],
});

await msg.channel.send({
  content: "this week's report",
  files: [new AttachmentBuilder('./report.pdf')],  // or Buffer/Blob + { name: '...' }
});

// Spoiler attachments render blurred until the viewer reveals them:
await msg.channel.send({
  files: [new AttachmentBuilder('./ending.png').setSpoiler()],
});

Limits mirror the server (10 embeds/message — title ≤256, description ≤4096, ≤25 fields; 10 files, 25 MB each) and the builders throw at build time where the server would silently truncate or drop. A message can carry embeds or attachments, not both (v1). The client renders one image slot per embed — setThumbnail wins over setImage if both are set.

Components & interactions

Attach buttons and select menus to messages and handle every interaction — clicks, selections, slash commands, modal submits — as one interaction event, discriminated by kind. No message reading required (Intents.Interactions is delivered bot-direct, and covers all kinds):

import { Client, Intents, ButtonBuilder, SelectMenuBuilder, ActionRowBuilder } from 'privage.js';

const client = new Client({ intents: [Intents.Interactions] });

await client.send(channelId, {
  content: 'Ticket #42',
  components: [
    new ActionRowBuilder().addButtons(
      new ButtonBuilder().setStyle('primary').setLabel('Claim').setCustomId('ticket:claim:42'),
      new ButtonBuilder().setStyle('link').setLabel('Logs').setURL('https://logs.example.com/42'),
    ),
    new ActionRowBuilder().addSelect(
      new SelectMenuBuilder().setCustomId('ticket:severity').setPlaceholder('Severity')
        .addOptions({ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }),
    ),
  ],
});

client.on('interaction', async (interaction) => {
  if (interaction.kind === 'select' && interaction.customId === 'ticket:severity') {
    await interaction.reply({ content: `Severity set to ${interaction.values![0]}`, ephemeral: true });
  }
  if (interaction.customId === 'ticket:claim:42') {
    await interaction.update({ content: `Ticket #42 — claimed by <@${interaction.user.id}>`, components: [] });
  }
});

Respond within 5 minutes with exactly one terminal callback — interaction.update(...) (edit the original; invalid for slash commands, which have no source message) or interaction.reply(...) — optionally after interaction.defer() (extends to 15 min). Ephemeral replies (reply({ ..., ephemeral: true })) are visible only to the interacting user and never persisted — great for confirmations and errors; valid for every kind. Selected values arrive on interaction.values (selects only). Replies can carry uploads — reply({ files: [...] }), same limits and ATTACH_FILES requirement as client.send (non-ephemeral only, no embeds/components alongside in v1).

Interactions are at-least-once work items within a connection's replay window. A pending interaction is reoffered when the gateway resumes a brief disconnect; after a full session reset or process restart, undelivered pending interactions expire (≤5-minute window) and the user simply clicks again. defer() on a reoffered, already-deferred interaction is a safe no-op. Keep handlers idempotent before their terminal reply()/update(), and defer before work that may outlive the initial window.

Limits: 5 rows/message; a row holds either ≤5 buttons or exactly one select (≤25 interactive components/message); label ≤80, custom_id ≤100 and unique per message; select options ≤25 with unique values. Link buttons take a URL and fire no event. Components can't share a message with file attachments (v1). editMessage(id, { components }) replaces a message's rows (e.g. disable a panel).

Slash commands

Register the bot's whole command set with a bulk overwrite (validated locally against the same grammar the server enforces), and handle invocations as interaction events with kind: 'command':

await client.commands.set([
  {
    name: 'ticket',                       // ^[a-z0-9_-]{1,32}$
    description: 'Open a support ticket', // ≤100 chars
    options: [
      { type: 'string', name: 'subject', description: 'What broke', required: true },
      { type: 'integer', name: 'severity', description: 'How bad',
        choices: [{ name: 'Low', value: 1 }, { name: 'High', value: 3 }] },
    ],
  },
]);

client.on('interaction', async (interaction) => {
  if (interaction.kind !== 'command' || interaction.command?.name !== 'ticket') return;
  const subject = interaction.command.getOption<string>('subject');
  await interaction.reply({ content: `Ticket opened: ${subject}`, ephemeral: true });
});

client.commands.fetch() lists the registered set; set([]) clears it (set replaces the whole registry each time — 5/60s rate limit). Option types: string | integer | number | boolean | user | channel | role (user/channel/role values arrive as id strings); required options must precede optional ones; ≤50 commands, ≤10 options each, choices (≤25) only on string/integer/number. Command interactions carry no source message — respond with reply(...) (normal or ephemeral), never update(...).

Modals

Open a form in response to a button/select/command interaction; the submission comes back as a fresh interaction with kind: 'modal_submit':

import { ModalBuilder, TextInputBuilder } from 'privage.js';

client.on('interaction', async (interaction) => {
  if (interaction.kind === 'button' && interaction.customId === 'ticket:open') {
    await interaction.showModal(
      new ModalBuilder().setTitle('Open a ticket').setCustomId('ticket-modal').addFields(
        new TextInputBuilder().setCustomId('subject').setLabel('Subject').setStyle('short'),
        new TextInputBuilder().setCustomId('details').setLabel('Details').setStyle('paragraph').setRequired(false),
      ),
    );
  }
  if (interaction.kind === 'modal_submit' && interaction.customId === 'ticket-modal') {
    await interaction.reply({ content: `Got it: ${interaction.getField('subject')}`, ephemeral: true });
  }
});

showModal(...) is only valid from a pending interaction, once — not after defer(), not in response to a modal submit (the SDK throws client-side before the server would 400), and it does not extend the 5-minute window. The submission is a brand-new interaction with a fresh window: read values via interaction.getField(customId) (or the raw interaction.fields), then reply(...) / defer()update(...) works only when the modal was opened from a message component. Limits: title and labels ≤45, 1–5 fields, short inputs cap at 1024 chars, paragraph at 4000.

Reactions, editing & fetching

await msg.react('👍');                    // add a reaction
await msg.removeReaction('👍');           // remove the bot's own
await msg.edit('updated text');           // edit the bot's own message
await msg.pin(); await msg.unpin();

const recent  = await client.fetchMessages(channelId, { limit: 50 });  // Message[]
const members = await server.fetchMembers();  // Member[]
const roles   = await server.fetchRoles();    // Role[] — e.g. roles.find(r => r.name === 'Mods')
await msg.member.setNickname('New Nick');     // null to clear

REST calls automatically back off and retry on a 429 (up to 3 attempts, respecting retryAfter).

Presence

Set the bot's own status and rich-presence activity (shown on its profile and in member lists):

import { ActivityType } from 'privage.js';

await client.setStatus('online');   // 'online' | 'idle' | 'dnd' | 'offline'
await client.setActivity({ type: ActivityType.Watching, name: 'the mod queue' });
await client.clearActivity();

Activities are validated server-side (name required, ≤128 chars; optional details, state, timestamps) and activity updates are rate-limited (~10/min). Bots can set presence but do not receive presence events (the presence intent is a no-op for bots in v1).

Polls

Create polls (the SDK already receives pollVote / pollClose events):

await client.createPoll(channelId, {
  question: 'Ship it?',
  options: ['Yes', 'No', 'Needs work'],   // 2–6 unique, ≤200 chars each
  deadline: Date.now() + 24 * 3600e3,     // required, within 7 days
});
await client.closePoll(pollId);            // creator or MANAGE_MESSAGES

Awaiting replies (collectors)

The standard confirmation/wizard building block — collect matching messageCreates until a count or time limit:

await msg.reply('Really ban them? Reply `yes` within 30s.');
const [answer] = await client.awaitMessages({
  channelId: msg.channelId,
  filter: (m) => m.author.id === msg.author.id && m.content === 'yes',
  timeMs: 30_000,
});
if (answer) await msg.member?.ban({ reason: 'confirmed' });

// or the event form:
const collector = client.createMessageCollector({ channelId, max: 5, timeMs: 60_000 });
collector.on('collect', (m) => console.log(m.content));
collector.on('end', (all, reason) => console.log(`${all.length} collected (${reason})`));

Mentions & formatting

Mention syntax on the wire is <@userId> / <@&roleId> / <#channelId>:

import { userMention, channelMention, bold, codeBlock } from 'privage.js';

await client.send(channelId, `${userMention(userId)} check ${channelMention(logsChannelId)} — ${bold('3 new alerts')}`);

@everyone / @here work as plain text (broadcast mentions are rate-limited server-side).

Webhooks

Bots with MANAGE_WEBHOOKS can provision channel webhooks:

const { webhook, token } = await client.createWebhook(channelId, { name: 'Deploys' });
await client.fetchChannelWebhooks(channelId);
await client.editWebhook(webhook.id, { name: 'CI' });
await client.rotateWebhookToken(webhook.id);   // old token dies immediately
await client.deleteWebhook(webhook.id);

Servers & channels

The SDK maintains a server/channel name cache for you — no per-message REST calls needed:

client.on('messageCreate', (msg) => {
  console.log(`[${msg.server?.name ?? 'DM'} / #${msg.channel.name}] ${msg.content}`);
});

client.channels.get(channelId);  // Channel | undefined
client.servers.get(serverId);    // Server | undefined

The cache is hydrated on connect (GET /servers, then each server's channel list in the background — ready never waits on it) and lazily filled on a cache miss (a channel not yet seen resolves itself in the background so the next message in it has a name). msg.channel.name is null until resolved; msg.channel.send(...) always works regardless.

Live updates (renames, new/deleted channels, server renames/deletes) require Intents.Servers — without it, the cache still hydrates on connect and lazily fills on miss, but won't reflect changes made after that until the next miss.

Managing roles

A bot with MANAGE_ROLES can grant/remove roles — the target role must sit below the bot's highest role, and its permissions must be delegatable by the bot (a 403 → PrivagePermissionError otherwise):

// respond to "!role" by giving the author a role
client.on('messageCreate', async (msg) => {
  if (msg.author.bot || !msg.member) return;      // member is null in DMs
  if (msg.content === '!role') await msg.member.addRole(ROLE_ID);
});

await msg.member.removeRole(ROLE_ID);
await client.addRole(serverId, userId, roleId);   // anyone, anywhere

memberJoin gives you a Member with the same helpers — handy for auto-roles:

client.on('memberJoin', (m) => { if (!m.bot) m.addRole(WELCOME_ROLE_ID); });

Moderation

Every mod action is on the Member (and mirrored on Client). Each needs the matching permission (KICK_MEMBERS / BAN_MEMBERS / TIMEOUT_MEMBERS / MANAGE_MESSAGES) and the bot must outrank the target:

await msg.member.kick({ reason: 'spam' });
await msg.member.ban({ reason: 'raid', deleteHistory: true });
await msg.member.timeout({ durationSeconds: 600, reason: 'cool off' });  // or { until: new Date(...) }
await msg.member.removeTimeout();
await msg.delete();                       // delete the offending message

// server-level / general form:
await client.unban(serverId, userId);
await client.kick(serverId, userId);
await client.deleteMessage(messageId);

Migrating from discord.js

The SDK is deliberately discord.js-shaped, and ships compat constants so common idioms port verbatim:

import { Events, Colors, ButtonStyle, TextInputStyle, SlashCommandBuilder } from 'privage.js';

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isChatInputCommand()) {
    await interaction.deferReply();       // alias of defer()
    const result = await slowWork();
    await interaction.editReply(result);  // alias of reply() — the deferred response IS the reply
  }
});

// Ready-state accessors match discord.js (true once ready fires, across reconnects):
if (client.isReady()) console.log(`up since ${client.readyAt} (${client.uptime}ms)`);

await client.commands.set([
  new SlashCommandBuilder()
    .setName('ticket')
    .setDescription('Open a support ticket')
    .addStringOption(o => o.setName('subject').setDescription('What broke').setRequired(true)),
]);

new EmbedBuilder().setColor(Colors.Blurple).setAuthor({ name: 'CI', iconURL: '...' });
// Colors.Blurple is an alias of Colors.Privage (brand purple) — ported bots go on-brand automatically.
new ButtonBuilder().setStyle(ButtonStyle.Primary).setLabel('Go').setCustomId('go');

The honest differences: intents are coarser and semantically different (no GatewayIntentBits alias on purpose — read the intents docs; interaction-driven bots need no message intent at all), the token goes to login(token) not the constructor, presence is client.setStatus/ client.setActivity (not client.user.setActivity), embeds/components can't share a message with file attachments, interactions are reoffered across brief resumed disconnects (not full restarts), and there's no sharding (the collapsed pipe scales without it). Full guide: Migrating from discord.js.

Errors

PrivageAuthError (bad/rotated token — login() rejects, no retry; also thrown from any REST call on a mid-session 401), PrivageRateLimitError (.retryAfter ms), PrivagePermissionError (missing permission / kicked — thrown from reply/send), PrivageConnectionError (emitted as connectionError for gateway drops while the SDK auto-reconnects, and thrown from REST calls that fail at the network level — those are not retried). Full guide: Error handling.

Roadmap

  • ✅ Messages (incl. threads), reactions (+ granular add/remove), edits, deletes, polls
  • ✅ Members, roles, nicknames; kick / ban / timeout moderation
  • ✅ Reply / send, edit, pin, react; fetch messages / members / roles
  • ✅ Server & channel name cache; auto reconnect + resume; 429 retry
  • ✅ Interactions: buttons, select menus, slash commands, modals, ephemeral replies
  • ⏳ Bot DMs (pending gateway support)

See docs/bot-gateway-protocol.md in the backend for the underlying wire spec.