Bot SDK

@missived/bot is a small TypeScript package for writing Missived bots: typed REST calls, a gateway client that resumes, signed slash commands, buttons, forms and private replies.

Before you start

Build the package, then import from its dist/index.js, or pack it and install the .tgz in your own project:

cd integrations/missived-bot-sdk
npm ci --ignore-scripts
npm run build        # writes dist/
npm pack             # makes missived-bot-0.1.0.tgz

Keep the token in your secret manager or environment, never in code. The SDK can't widen anything: scopes, channel membership and the rule that apps never read direct messages are all enforced by Missived.

REST

import { RestClient, type Schemas } from '@missived/bot';

const rest = new RestClient({ token: process.env.MISSIVED_TOKEN! });
const me = await rest.call('getMe', {});

const channels = rest.paginate(cursor => rest.call('listChannels', {
  path: { orgId: 'org-id' }, query: { cursor, limit: 100 },
}));
for await (const channel of channels) console.log(channel.name);

const body: Schemas['NewMessage'] = { content: 'Build passed' };
await rest.createMessage('channel-id', body, { idempotencyKey: 'build-123' });

Files

Upload with files:write, then attach. Only the token that uploaded a file can attach it, and the API takes files up to 25 MB.

const file = await rest.uploadFile('org-id', { name: 'build.log', type: 'text/plain', data: logBytes });
await rest.createMessage('channel-id', { content: 'Build log', attachment_ids: [file.id] });

const { data, fileName } = await rest.downloadAttachment(message.attachments[0].id); // needs messages:read

More in Files.

Gateway

GatewayClient holds one gateway connection: it discovers the socket from /v1/gateway, identifies, heartbeats, and reconnects with backoff.

import { GatewayClient, RestClient } from '@missived/bot';

const token = process.env.MISSIVED_TOKEN!;
const gateway = new GatewayClient({
  token, rest: new RestClient({ token }), intents: ['messages'],
  onReady: ready => console.log(ready.bot.handle),
  onInvalidSession: () => console.log('Catch up over REST before continuing'),
  onError: error => console.error(error.message),
});

gateway.on('message.created', async event => {
  if (event.mentioned) console.log(event.data.message.content);
});

await gateway.start();
// gateway.stop() closes the socket and every timer.

Intents are messages, message_content, reactions, members, channels, meetings and shifts; each needs its scope (Intents). Message text is optional in every event: it arrives only as channel membership, the message_content intent and the org's content policy allow.

Sequence numbers and resume

Slash commands over HTTP

nodeInteractionHandler is a Node http handler for your app's interactions URL (Slash commands).

import { createServer } from 'node:http';
import { nodeInteractionHandler, reply, defer } from '@missived/bot';

createServer(nodeInteractionHandler({
  publicKey: process.env.MISSIVED_PUBLIC_KEY!,
  applicationId: 'app-id',
  commands: { roll: () => reply('6'), later: () => defer() },
})).listen(4151, '127.0.0.1');

For each request it:

  1. accepts only POST and caps the raw body at 64 KB;
  2. verifies the Ed25519 signature over the timestamp plus the exact body bytes, and rejects timestamps more than 5 minutes off;
  3. only then parses the JSON, and claims the interaction's id so a replay is refused (409);
  4. answers a ping with { "type": 1 }, and routes commands by name.

Mount it before any JSON body parser, on a path of its own. Your interactions URL must be public HTTPS, so put a proxy in front of the local port; Missived refuses private and loopback addresses. Saving the URL sends a signed ping.

Answer within 3 seconds. The SDK doesn't defer a slow handler for you: return defer() and do the work in afterResponse, which runs after the answer has been sent and gets the context's editOriginal, deleteOriginal, followUp and option(name). Those calls use only the 15-minute interaction token, never your bot token; keep that token out of logs. Missived allows 20 follow-ups per interaction. In production, put real work on a durable queue: work held in memory is lost if the receiver restarts.

Other frameworks: createInteractionHandler(options) takes { method, headers, rawBody } and returns { status, body, afterResponse }. Send the answer, then call afterResponse().

The replay check is an in-memory cache by default. With several receivers, or to survive restarts, pass claim(id, expiresAt) backed by shared storage; it must refuse an id it has seen until it expires. A failed handler keeps its claim, so its side effects never run twice.

Buttons, menus, forms and private replies

The builders produce Discord-shaped components and throw a ValidationError, in the server's own words, before anything is sent: at most 5 rows of 5 buttons or one select, custom_id 1 to 100 characters and unique, labels up to 80, https link buttons, selects of 0 to 25 values, forms of 1 to 5 text inputs with a title up to 45.

import {
  actionRow, button, linkButton, stringSelect, selectOption, modal, textInput,
  nodeInteractionHandler, prefix, reply, privateReply, update, openModal,
} from '@missived/bot';

const row = actionRow(
  button('vote:yes', 'Yes', { style: 'success' }),
  button('vote:no', 'No', { style: 'danger' }),
  linkButton('https://example.com/rules', 'Rules'),
);
const pick = actionRow(stringSelect('env', [selectOption('Production', 'prod'), selectOption('Staging', 'stage')]));
const form = modal('feedback', 'Feedback', [textInput('why', 'Why?', { style: 'paragraph', maxLength: 500 })]);

nodeInteractionHandler({
  publicKey, rest,
  commands: { poll: () => reply({ content: 'Ship it?', components: [row, pick] }) },
  components: [
    [prefix('vote:'), c => update(`${c.user?.name} voted ${c.params[0]}`)],
    ['env', c => privateReply(`You chose ${c.values[0]}`)],
    [/^ticket:(\d+)$/, () => openModal(form)],
  ],
  modals: { feedback: c => privateReply(`Thanks: ${c.field('why')}`) },
});

Routing by custom_id

components and modals take either an object of exact ids or a list of [pattern, handler] pairs tried in order. A pattern is an exact string, prefix('vote:') (the rest is c.params[0]) or a regular expression (its groups are c.params). Anything unmatched goes to onInteraction. Put state you need later in the id, such as roll:again:20.

c.values holds a select's chosen values (or user, role or channel ids), c.message the message the component is on, c.isPrivate whether that is a private reply. In a form, c.field(id) is a trimmed answer.

Answers

Helper Type Does
reply(data) 4 Posts a reply as the bot
privateReply(data) or reply(data, { private: true }) 4 + flag 64 A reply only the person sees, kept 15 minutes
defer() / defer({ private: true }) 5 A "thinking" placeholder to finish later
deferUpdate() 6 Acknowledge a click now, then editOriginal
update(data) 7 Edit the message the component is on (omitted fields stay)
openModal(form) 9 Open a form

Every answer is checked before it leaves: a command can't update, a form can't open another form. A refused answer becomes a 500 and goes to onError. In afterResponse, followUp(body, { private: true }) or privateFollowUp(body) sends a private follow-up. validateComponents and validateModal check shapes you wrote by hand, for example before rest.createMessage(..., { components }).

The examples

The SDK ships four examples. Set the token and the other values in your environment, then run one. Each slash example replaces the app's whole command list with its own commands.

Example Needs Shows
examples/run-echo.ts messages:write Gateway bot that answers @mentions
examples/run-slash.ts commands:write, messages:write /roll [sides] and a deferred /later
examples/roll-buttons/run.ts the same /roll with "Roll again" and a "Custom…" form
examples/private-reply/run.ts the same /peek private select, /report deferred private reply
export MISSIVED_URL=https://api.missived.com
node examples/run-echo.ts                       # MISSIVED_TOKEN set
PORT=4151 node examples/roll-buttons/run.ts     # also MISSIVED_PUBLIC_KEY (the app's key) and MISSIVED_ORG_ID

The echo bot asks only for the messages intent, ignores its own posts, replies in the thread, and uses the event id as the idempotency key:

gateway.on('message.created', async event => {
  const message = event.data.message;
  if (!event.mentioned || message.author?.id === botId) return;
  await rest.createMessage(message.channel_id,
    { content: `Echo: ${message.content ?? 'You called?'}`, reply_to: message.parent_message_id ?? message.id },
    { idempotencyKey: `echo-${event.id}` });
});

When the org sends thin payloads, the text is missing and it answers "You called?".

The roll-buttons bot registers its command, then carries the die size in the button's id:

export function registerRollButtons(rest: RestClient, orgId: string) {
  return rest.registerCommands(orgId, [command('roll', 'Roll a die', [
    { name: 'sides', description: 'Number of sides', type: 'integer', required: false, min_value: 2, max_value: 100 },
  ])]);
}

function rolled(sides: number, who = 'Someone') {
  return {
    content: `${who} rolled **${randomInt(1, sides + 1)}** on a d${sides}`,
    components: [actionRow(
      button(`roll:again:${sides}`, 'Roll again'),
      button('roll:custom', 'Custom…', { style: 'secondary' }),
    )],
  };
}

const customForm = modal('roll:custom', 'Custom roll', [
  textInput('sides', 'Number of sides', { placeholder: '2 to 100', minLength: 1, maxLength: 3 }),
]);

nodeInteractionHandler({
  ...options,
  commands: { roll: c => reply(rolled(Number(c.option('sides') ?? 6), c.user?.name)) },
  components: [
    [prefix('roll:again:'), c => update(rolled(Number(c.params[0]), c.user?.name))],
    ['roll:custom', () => openModal(customForm)],
  ],
  modals: {
    'roll:custom': c => {
      const sides = Number(c.field('sides'));
      if (!Number.isInteger(sides) || sides < 2 || sides > 100) return privateReply('Pick a whole number from 2 to 100.');
      return update(rolled(sides, c.user?.name));
    },
  },
});

A deferred reply's placeholder can land a moment after your HTTP answer. The /later and /report examples retry editOriginal on a 404 for up to five seconds, which is a good pattern to copy.

Troubleshooting