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.jsQuick 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 cacheIn-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 clearREST 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_MESSAGESAwaiting 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 | undefinedThe 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, anywherememberJoin 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.
