The Hub plugins

Plugins add features to The Hub without changing its code: slash commands, buttons next to the message box, decorations on messages, extra themes and more. They are plain JavaScript (plus optional CSS), with no build step.

  • The server owner installs plugins, by putting their folders in plugins/ or from the plugin shop in Settings → Plugins (see The plugin shop). Only the accounts in OWNER_USERNAMES can install from the shop, and only from the catalog set in config.php: there is no "install from any link".
  • Each person chooses which installed plugins they use, in Settings → Plugins. The choice is saved in their browser and takes effect right away, without reloading.

Two examples ship with The Hub, both off until someone turns them on:

FolderWhat it shows
plugins/dice/The reference plugin: /roll, a composer button, a settings panel, a message decorator, an event and a stylesheet.
plugins/dusk-theme/A plugin that only adds a theme.

Security: read this first

A plugin runs inside The Hub as the person who turned it on. It can read everything they can see and do everything they can do: send messages, change their profile, leave spaces. Only install plugins you wrote yourself or whose code you have read and trust, and keep them up to date like the rest of The Hub.

The server never runs plugin code: plugins/.htaccess stops PHP and other server scripts in this folder (on Apache).

Installing a plugin

  1. Upload the plugin's folder to plugins/, so you have plugins/<id>/plugin.json next to its files. The folder name must be the plugin's id.
  2. That's it. Everyone sees it in Settings → Plugins the next time they open The Hub (or reload).

To update a plugin, replace its files and raise "version" in plugin.json (browsers fetch the new files when the version changes). To turn a plugin off for everyone, set "enabled": false in its plugin.json. To remove it, delete its folder.

If a folder is ignored because something is wrong with it, The Hub owner (the accounts in OWNER_USERNAMES in config.php) sees why under Left out at the bottom of Settings → Plugins.

The plugin shop

Settings → Plugins → Plugin shop lists the plugins in a catalog: set PLUGIN_SHOP_URL in config.php to an https:// link to the catalog's JSON file, or to the path of one inside this Hub (like plugin-shop/index.json). Everyone can browse it; The Hub owner installs, updates and removes plugins from it. plugin-shop.example.json is a sample catalog:

{
    "plugins": [
        {
            "id": "dice",
            "name": "Dice roller",
            "version": "1.0.0",
            "author": "The Hub",
            "description": "Roll dice in chat with /roll 2d6.",
            "homepage": "https://example.com/voicelink-dice",
            "download": "https://example.com/voicelink-dice/dice-1.0.0.zip",
            "sha256": "64 hex characters: the SHA-256 of that exact zip file",
            "tags": ["fun", "games"]
        }
    ]
}
  • id, name, version and download follow the same rules as in plugin.json, and must match the plugin.json inside the zip. download is an https:// link, or a path next to the catalog when the catalog is on this server.
  • sha256 is required: a download that doesn't match it is refused. Make it with sha256sum plugin.zip (Linux, macOS) or Get-FileHash plugin.zip (Windows PowerShell).
  • The zip holds the plugin's files, at its top or inside one folder. Only plugin files are accepted (js, css, json, md, txt, images, fonts and sounds), up to 3 MB zipped and 6 MB unpacked.

Installing unpacks the plugin into plugins/<id>/ and writes a .shop.json there, so plugins/ must be writable by PHP. The shop never replaces or removes a plugin that was installed by hand. Turning a plugin on is still each person's choice, and the same warning applies: a plugin runs with the account of whoever turns it on, so only list plugins you trust in your catalog.

plugin.json

{
    "id": "dice",
    "name": "Dice roller",
    "version": "1.0.0",
    "author": "The Hub",
    "description": "Roll dice in chat with /roll 2d6, or roll a d20 with the button next to the message box.",
    "main": "plugin.js",
    "styles": "plugin.css",
    "default": false
}
FieldRequiredRules
idyes1–40 characters: a-z, 0-9 and -, starting with a letter or digit. Must equal the folder name.
nameyesUp to 60 characters.
versionyesLetters, digits, ., + and - (like 1.0.0). Raise it when you change the files.
authornoUp to 60 characters.
descriptionnoUp to 300 characters, shown in Settings → Plugins.
mainyesThe script: a plain .js file name inside the plugin's folder (no slashes, no ..).
stylesnoA stylesheet: a plain .css file name inside the plugin's folder. Loaded only while the plugin is on.
defaultnotrue turns the plugin on for people who haven't chosen yet. Defaults to false.
enablednofalse hides the plugin from everyone without deleting it. Defaults to true.

A manifest that breaks any rule is ignored as a whole.

Writing a plugin

A plugin's script calls TheHub.plugin() with its id and a setup function. Setup runs each time the plugin is turned on, and receives the api object:

TheHub.plugin('hello', api => {
    api.commands.register({
        name: 'hello',
        description: 'Say hello to everyone',
        run: () => `👋 Hello from ${api.me().display_name}!`,
    });
    api.ui.toast('Hello plugin is on');
});

The rules of the road:

  • Call TheHub.plugin() right away, at the top level of your main script, with the id from your plugin.json. Calls made later (after a fetch, in a timer) or with another plugin's id are refused.
  • Do your work inside setup. Code outside it runs only once per page load, even if the plugin is turned off and on again.
  • Everything you add through api is removed for you when the plugin is turned off: commands, buttons, event listeners, decorators, styles, themes, its settings panel and any modal it opened. Messages are redrawn without your decorations.
  • If you set up anything yourself (a setInterval, a listener on window, elements you added outside the places api gives you), return a cleanup function from setup. It runs when the plugin is turned off. Setup may also be async, as long as TheHub.plugin() itself is called right away.
  • Escape text before you put it in HTML. Messages, names and topics come from other people. Use api.util.esc(text), or set textContent instead of innerHTML.
  • Prefix your CSS classes (for example dice-…) or scope rules to [data-plugin="<id>"].

Every call into your code is guarded: if it throws, the error is shown in your plugin's entry in Settings → Plugins and logged to the console, and the rest of The Hub (and other plugins) carry on. If setup throws, everything it had registered is removed and the plugin stays off until it's turned on again.

API reference

Basics

MemberWhat it is
api.idYour plugin's id.
api.manifestYour manifest as The Hub read it: { id, name, version, author, description, main, styles, default }.
api.versionThe Hub version, like "8.0.0". Also available as The Hub.version.

Events: api.on(event, fn) → off()

const off = api.on('message', msg => {
    if (!msg.mine && /good night/i.test(msg.content)) api.ui.toast(`${msg.author.display_name} is off to bed`);
});
EventWhenfn receives
'ready'Once per turn-on, after setup and once The Hub has finished its first sync.nothing
'message'A new message arrived in the channel or conversation that's open, including your own. Not for history being loaded or for edits.a message object (below)
'channel'The open channel or conversation changed.the same object as api.channel(), or null
'voice'You joined or left a voice room or call.`{ joined: true \false, channelId }`
'theme'The theme changed.{ theme, applied }: the chosen theme ('night', 'auto', 'custom:dusk'…) and the look in effect ('night', 'day', 'mono'…)

Everything a handler receives is a copy: changing it changes nothing in The Hub.

Message objects look like this:

{
    id: 42, channel_id: 7,
    author: { id, username, display_name, avatar, color, badge, name_style, … },
    content: 'hi <@&3>',    // raw text with Markdown; role mentions are <@&roleId>
    attachments: [{ url, name, size, type, … }],
    sticker: null,          // or { name, url, … }
    reply: null,            // or { id, author, content, … }
    reactions: [], pinned: false,
    created_at: 1759660000000, edited_at: null,
    mine: true,             // added for plugins: you sent it
    pending: true,          // decorators only: your own message is still being sent ('message' events come after it's sent)
}

If your 'message' handler sends messages, ignore msg.mine ones, or you'll answer yourself forever.

Slash commands: api.commands.register({ name, description, run }) → off()

api.commands.register({
    name: 'flip',                        // the user types /flip; a-z, 0-9 and -, up to 32 characters
    description: 'Flip a coin',          // shown in the suggestions and in Settings → Plugins
    run(args, ctx) {                     // args: the text after the name; ctx: { name, channel }
        return Math.random() < .5 ? '🪙 Heads' : '🪙 Tails';
    },
});
  • Return a string to send it as the person's message in the open channel.
  • Return nothing when you've handled it yourself: nothing is sent.
  • Return false when you couldn't use what was typed (bad arguments, say): nothing is sent and the person's text goes back in the message box so they can fix it.
  • run may be async. If it throws, the person sees an error and gets their text back in the message box.
  • Commands can't replace The Hub's built-in ones (/shrug, /tableflip, /unflip, /lenny, /disapprove), and the first plugin to register a name keeps it.
  • While someone types / and the start of a name, matching plugin commands are suggested above the message box.

Message decorations: api.messages.decorate(fn) → off()

fn(el, msg) runs for every message drawn in the chat, each time the list is redrawn. el is the message's element (.msg, with the text in .msg-content); msg is a message object. Change only el and what's inside it.

api.messages.decorate((el, msg) => {
    if (!msg.content.includes('#todo')) return;
    el.querySelector('.msg-content')?.insertAdjacentHTML('beforeend',
        `<span class="todo-tag" title="${api.util.esc(msg.author.display_name)}'s to-do">✔ to-do</span>`);
});

Interface

MemberWhat it does
api.ui.toast(text, type)A short notice at the bottom of the screen. type: '', 'success' or 'error'.
api.ui.addComposerButton({ icon, label, onClick }) → off()A button next to the message box. icon: a name from The Hub's icon set (like 'star', 'music', 'smile'), an emoji, or an <svg> string (24×24, stroke="currentColor"). label is its tooltip. onClick(event).
api.ui.settings(render) → off()Your panel in Settings → Plugins, under your plugin's entry while it's on. render(el) fills the empty element el, each time the panel is shown. The Hub's own classes work here: setting-row, switch, btn btn-secondary btn-sm… (see the dice plugin).
api.ui.modal(title, html) → elementOpens a dialog. title is plain text; html is the body, which you must escape. Returns the dialog element so you can add listeners. It closes when the plugin is turned off.

Styles: api.styles.add(cssText) → off()

Adds a <style> element. For a stylesheet file, use "styles" in plugin.json instead. Use The Hub's colour tokens so you match every theme: var(--text), var(--muted), var(--s3), var(--line), var(--air) (the accent), var(--air-soft), var(--rose)… (the full list is at the top of assets/app.css).

Storage: api.storage.get(key, fallback), api.storage.set(key, value)

Small JSON values saved in this browser, separately for each plugin (in localStorage, under voicelink.plugin.<id>). set(key, undefined) removes a key. Nothing is shared between devices or people.

The person and the conversation

MemberWhat it does
api.me()A copy of the logged-in user: { id, username, display_name, avatar, color, status, … }.
api.channel()The open channel or conversation, or null: `{ id, type: 'text' \'voice' \'dm', name, topic, spaceId, spaceName, user } (user` is the other person, in a DM).
api.send(text) → PromiseSends text to the open channel or conversation, as the person.
api.request(action, data, opts) → PromiseCalls The Hub's own API (api.php?action=…) as the person; opts.method is 'POST' (default) or 'GET'. Resolves to the JSON reply, rejects with an Error whose message the server gave. The endpoints are the case '…': entries in api.php.

Themes: api.themes.register(theme) → off() or null

Adds a theme people can pick in Settings → Appearance. It's removed when the plugin is turned off, and anyone using it goes back to its base theme.

api.themes.register({
    id: 'dusk',                   // a-z, 0-9 and -, up to 40 characters
    name: 'Dusk',                 // up to 40 characters
    base: 'night',                // 'night', 'day', 'mono' or 'mono-light': what it starts from
    vars: { '--bg': '#1f1420', '--s1': '#2a1a29', '--air': '#ff9e5e' },   // colour tokens to override
    css: '',                      // optional extra CSS
    author: 'You', description: 'Plum dusk with a warm orange glow',
});

Only The Hub's theme colour tokens can be set in vars, with plain colour values. Returns null (and shows why in Settings → Plugins) if the theme isn't valid or this Hub can't add themes. See plugins/dusk-theme/.

Utilities

MemberWhat it does
api.util.esc(text)Escapes & < > " ' so text is safe inside HTML and attribute values.
api.util.icon(name, size = 20)The <svg> markup of one of The Hub's icons, or '' if there's no such icon.

Debugging

  • Settings → Plugins shows each plugin's last errors under its entry: a script that couldn't load (missing file), a syntax error, a setup that threw, or an error in one of its commands, events, decorators or buttons (including a promise it rejected without catching). A plugin that is switched on but not running has a red outline.
  • The browser console logs every error with its plugin id, like [plugin dice] Setup: TypeError: …, with the full stack trace.
  • A plugin that doesn't appear at all was left out: log in as The Hub owner and look at the bottom of Settings → Plugins, or check plugin.json against the table above.
  • Changed files not showing up? Raise "version" in plugin.json, then reload.
  • To try changes quickly, turn the plugin off and on in Settings → Plugins: setup runs again (the script itself is only fetched again after a reload).

This page is built from plugins/README.md in The Hub's source.