Sblurp

Plugins

A plugin is a JavaScript file that runs on your server. It can roll dice, greet people, post on a timer and check who gets in. It runs in a sandbox with no network and no files.

Make one with AI

You do not need to write code. Give an AI assistant, such as Claude, this page as Markdown: https://sblurp.chat/docs/plugins.md. Then say what the bot must do, for example: "Write a Sblurp plugin that welcomes each new member by name and posts a daily question at 9 am." The assistant writes one .js file. Install it as shown below, and try it in a test channel first.

Install a plugin

You must be an admin or the owner of the community. There are three ways to install a plugin. Changes in the app and with the API apply at once. You do not restart the server.

In the app

  1. Open the community menu (the community name at the top of the sidebar) and select Community settings.
  2. Select the Bots tab.
  3. Under Install a plugin, choose a .js file, or paste the source.
  4. Check the name. The app fills it in from the file name. The name is also the bot's username.
  5. Select Install.

The plugin list shows each plugin and its status: Running, Off or Did not load. If a plugin has an error, the error is shown under its name.

  • To stop a plugin, turn off its switch. The server keeps the file. Turn the switch on to start the plugin again.
  • To replace a plugin, install a new file with the same name. If the new code does not load, the old code keeps running.
  • To remove a plugin, select the trash icon, then confirm. This also removes the plugin's bot. Its messages stay.

The server rejects code that has a syntax error, throws an error at the top level or runs for longer than the time limit. The maximum size is 256 KB.

With the API

The BotService has four calls for plugins: ListPlugins, InstallPlugin, SetPluginEnabled and DeletePlugin. See the BotService reference. Use the access token of an admin's account (see Authentication). A bot token does not work, because a bot is never an admin.

jq -n --rawfile src dice.js '{name: "dice", source: $src}' |
  curl https://chat.example.com/sblurp.v1.BotService/InstallPlugin \
    -H "Authorization: Bearer $TOKEN" \
    -H 'Content-Type: application/json' \
    -d @-

Each change sends a plugins_changed event, so open admin screens show the new list.

As a file

  1. Put a .js file in the plugins folder inside the data folder, such as /var/lib/sblurp/plugins/dice.js.
  2. Restart the server.

A plugin that is turned off has the file name dice.js.disabled.

With Docker, copy the file into the volume:

docker compose exec server mkdir -p /data/plugins
docker compose cp dice.js server:/data/plugins/dice.js
docker compose restart server

Each plugin becomes a bot member named after the file: dice.js is the bot dice. The name must be a valid username (2 to 32 of a-z, 0-9, _, . and -) and must not be a person's username.

The dice example

server/plugins/examples/dice.js rolls dice with /roll 2d6, and lets in only people with a confirmed email address.

// dice.js: "/roll 2d6" rolls dice, and only people with a verified email
// address may join. Copy it to <data>/plugins/ to install it. The bot is
// called "dice", after the file name.

function onJoin(member) {
  if (!member.email_verified) {
    return { allow: false, reason: "Verify your email address with your account provider, then join again." };
  }
}

function onCommand(name, args, ctx) {
  if (name !== "roll") return;
  const m = /^(\d*)d(\d+)$/.exec(args || "1d6");
  const count = Number(m?.[1] || 1), sides = Number(m?.[2]);
  if (!m || count < 1 || count > 100 || sides < 2 || sides > 1000) {
    return "Usage: /roll NdM, for example /roll 2d6 (up to 100 dice of up to 1000 sides)";
  }
  const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides));
  const total = rolls.reduce((a, b) => a + b, 0);
  return `${ctx.author.display_name} rolled ${args || "1d6"}: ${rolls.join(" + ")} = ${total}`;
}

Hooks

A plugin defines any of these global functions. Each is optional.

onJoin(member)

Runs before someone new, or returning, joins. member has:

Field What
iss, sub Who issued their login, and their account id there.
username, display_name Their username and the name they chose to share.
email_verified Whether their email address is confirmed.
age_over_13, age_over_18 What they declared. False when the issuer does not say.
answer Their answer to joinQuestion, if the plugin has one.

Return { allow: false, reason } to turn them away. The reason is shown to them, up to 200 characters. Anything else lets them in.

onMessage(msg)

Runs after a person posts in a channel the plugin's bot can see. msg has id, text, thread_root_id, author { id, username, display_name } and channel { id, name }. Return a reply, or nothing.

onCommand(name, args, ctx)

Runs for a message like /roll 2d6. name is 1 to 32 of a-z, 0-9, _ and -. args is the rest of the line. ctx has message_id, thread_root_id, author and channel.

Every plugin sees every command. Ignore the ones that are not yours.

joinQuestion

A global string, read when the plugin loads. One line, up to 200 characters. The join screen asks it, and the answer arrives as member.answer in onJoin. Answers are trimmed and up to 1,000 characters.

var joinQuestion = "What is the secret word?";

function onJoin(member) {
  if ((member.answer || "").toLowerCase() !== "owls")
    return { allow: false, reason: "That is not the word. Ask a member." };
}

every(minutes, fn)

Call it when the plugin loads. fn() then runs every minutes minutes, whole minutes, at least 1. Return a reply with a channel.

every(60, () => ({ text: "Hourly reminder: be kind.", channel: "#general" }));

Replies

  • A string is posted where the message was, in its thread if it was in one.
  • { text, channel: "#general" } is posted in that channel, outside threads.

A bot can only post where it can see. Bot messages never trigger hooks.

Limits

  • 32 MB of memory and 1 second per hook call.
  • Hooks run one at a time, in a queue. onJoin runs inline, while the person waits to join.
  • A hook that throws, runs out of time or runs out of memory is logged and skipped. A plugin whose runtime died is restarted from its file, so its top-level state resets.
  • No files, network, environment or timers. QuickJS runs under WASI with /dev/null as its root, and the std and os modules are removed.
  • State lives in memory only and is lost on restart.
  • console.log goes to the server log.

Plugins run in QuickJS compiled to WebAssembly, on wazero. It needs no C compiler, and the sandbox enforces the memory and time limits.