Gryt

Plugins

Code that runs inside your server, what it can hear, and why installing one is a bigger decision than a theme

A server plugin is JavaScript that runs inside your Gryt server. It hears about things as they happen — a message being sent, somebody joining, somebody being banned — it can kick and ban, and it runs with everything the server has.

That last part is the whole story, so it comes first.

Installing a plugin is running somebody else's code on your machine, with your database. It can read your files, open connections, and do anything the server process can do. The capability list in a manifest is a plugin telling you what it means to do. It is not a wall around it.

There's no sandbox and no plan for one that would still be useful. Install plugins you'd trust the author with, and read the code if you can. If that sounds like too much, you probably want a bot instead — a bot talks to your server over the network like any other client, sees only what you let it into, and can be turned off without touching the server.

Turning them on

Plugins are off until you say otherwise. Set GRYT_PLUGINS_DIR to a folder:

GRYT_PLUGINS_DIR=/srv/gryt/plugins

There's no default path on purpose. A default would mean upgrading your server could start running whatever happened to be sitting in a directory you'd forgotten about.

Each plugin is a folder inside that one. Gryt reads them at startup, so adding or removing one means a restart.

What a plugin looks like

Two files. A manifest.json:

{
  "id": "automod",
  "name": "Automod",
  "version": "1.0.0",
  "main": "index.mjs",
  "description": "Watches for people posting the same thing in every channel",
  "author": "you",
  "capabilities": ["messages:read", "members:read"]
}

And the entry point, which exports activate:

export function activate(api) {
  api.log.info("watching");

  api.on("message:created", (message) => {
    api.log.info(`${message.nickname} said ${message.text.length} characters`);
  });
}

Either ESM or CommonJS works. A default export is used if there's no activate.

What a plugin can hear

Three events so far:

EventWhenNeeds
message:createdSomebody sends a message in a channelmessages:read
member:joinedSomebody joins for the first time, or comes back after a kickmembers:read
member:leftSomebody leaves, is kicked, or is bannedmembers:read

And one thing it can do, covered below:

ActionWhat it doesNeeds
api.moderation.kick / .banRemoves a member, not a moderatormoderation
api.moderation.deleteMessageTakes a post downmoderation

member:joined doesn't fire on a reconnect. Somebody whose wifi drops twenty times an hour joins once.

member:left carries reason, which is "left", "kicked" or "banned", so a plugin can tell a departure from a moderator having already dealt with it.

Two kinds of message you won't see

Direct messages. They're between two people. A plugin you installed doesn't get to read them.

Encrypted messages. Gryt holds the ciphertext and nothing else, so there's nothing to hand over. These are skipped rather than delivered with empty text, which would look like somebody sending nothing.

What a plugin can do about it

Kick and ban, with the moderation capability:

const result = await api.moderation.kick(message.userId, { reason: "spam" });
if (!result.ok) api.log.warn(`could not kick: ${result.reason}`);

// Permanent unless you say otherwise.
await api.moderation.ban(message.userId, { reason: "spam", durationMs: 60 * 60 * 1000 });

Both take the member id that comes on every event, and both hand back { ok: true } or { ok: false, reason } rather than throwing. A refusal is an ordinary answer — the member is a moderator, or they already left — and your plugin should log it and carry on.

Reaching for api.moderation without declaring the capability does throw, at startup, so you find out then rather than on the first person you try to ban.

It can't touch a moderator

A plugin holds no role, so there's nothing to compare it against the way one moderator outranks another. The rule that replaces that is simple: a plugin can't act on a moderator, or on you.

Anybody holding kick, ban, mute, manage messages or manage roles is out of reach. Manage roles is in that list because giving yourself the ban permission is the same thing one step removed.

It's deliberately not "anybody below rank 40" or similar. Ranks are yours to arrange, and a rule written against the numbers would mean reordering your roles quietly changed who a plugin could ban.

It can only do twenty of these a minute

Per plugin. That's not there to stop a plugin that means harm — one that does has the whole Node runtime and doesn't need this API. It's there for the plugin with a bug that bans everybody who speaks, which is a much more likely Tuesday.

Twenty is far more than a real automod does on a normal day and far fewer than a loop gets through before somebody notices. Past that, calls come back { ok: false } and the server log says so loudly.

You'll be able to tell it wasn't a person

A plugin isn't a member, so it doesn't borrow anybody's name. A ban it places shows plugin:automod where a moderator's name would go, and the audit log records plugin:ban rather than the ban a person writes. If you're ever looking at a ban wondering who did it, that's the answer.

It can delete a message too

Usually the more proportionate answer. Most spam wants the post gone rather than the person:

const result = await api.moderation.deleteMessage(message.channelId, message.messageId);

Both ids come straight off message:created. It's the same delete a moderator does from the app, so the attachments go with it and it disappears from everybody's screen the same way.

Channels only, same as reading. And it won't delete a moderator's message — that's acting on a moderator, quieter than banning them but the same kind of thing. A message from somebody who has since left is still fair game, so somebody leaving doesn't strand their spam.

Asking for permission

capabilities in the manifest is the list. There are four:

CapabilityWhat it lets a plugin do
messages:readRead every message sent in a channel
members:readSee who joins and leaves, and the invite code they used
moderationKick and ban members and delete their messages, but not a moderator’s
messagingExchange its own messages with the copy of itself in people’s clients

Subscribing to an event, or reaching for api.moderation, without declaring what it needs throws at startup with the line named — rather than silently never firing, which is a much worse afternoon.

A capability Gryt doesn't recognise is ignored rather than refused, so a plugin written for a newer version still loads on an older server. It just doesn't get the new part.

The list is there so you can read what a plugin intends before you run it, and so the log says what was running when you go back and look. It is not enforcement, as above.

Writing one

Make the folder

mkdir -p /srv/gryt/plugins/watchdog

manifest.json

{
  "id": "watchdog",
  "name": "Watchdog",
  "version": "1.0.0",
  "main": "index.mjs",
  "capabilities": ["messages:read", "members:read", "moderation"]
}

index.mjs

// More than five messages in ten seconds and you are out for an hour.
const RECENT = new Map();
const WINDOW_MS = 10_000;
const LIMIT = 5;
const BAN_MS = 60 * 60 * 1000;

export function activate(api) {
  api.on("message:created", async (message) => {
    const now = Date.now();
    const times = (RECENT.get(message.userId) ?? []).filter((t) => now - t < WINDOW_MS);
    times.push(now);
    RECENT.set(message.userId, times);

    if (times.length <= LIMIT) return;

    // The post first, so it is gone whether or not the ban lands.
    await api.moderation.deleteMessage(message.channelId, message.messageId);

    const result = await api.moderation.ban(message.userId, {
      reason: `${times.length} messages in ten seconds`,
      durationMs: BAN_MS,
    });

    // Worth logging rather than ignoring. Most of the time this is Gryt saying
    // the person is a moderator, which is exactly what you want it to say.
    if (result.ok) api.log.info(`banned ${message.nickname ?? message.userId} for an hour`);
    else api.log.warn(`did not ban ${message.userId}: ${result.reason}`);
  });

  api.on("member:left", (member) => {
    // They are gone, so the window is not worth keeping.
    RECENT.delete(member.userId);
  });
}

Restart

docker compose restart gryt-server

The log tells you what loaded:

ℹ Loading plugins from /srv/gryt/plugins
ℹ plugin watchdog 1.0.0 started (messages:read, members:read, moderation)
✔ 1 plugin loaded: watchdog

When it doesn't load

Gryt names the folder and the reason, then carries on starting. A plugin never stops your server booting.

no manifest.json — the folder has no manifest, so it isn't a plugin. Often a leftover directory sitting next to the real ones.

manifest.json could not be read — it's there and it isn't valid JSON. A trailing comma is usually the culprit.

name is missing, and the same for id, version and main — the field is absent or blank.

id ... must be lower case letters, digits, dot, dash or underscore — the id becomes part of a path, so it's kept to something that can be one.

main resolves outside the plugin folder — the entry point points somewhere else on disk.

id automod is already used by a-copy — two folders claim the same id. The first one alphabetically wins and the second is skipped.

failed to start and was skipped — the plugin threw while loading. The stack trace is in the log, and anything it subscribed before it threw is dropped.

When it stops working

A plugin that throws while handling an event doesn't take anything else down. The error is logged with the plugin and the event named, and the next plugin still gets called.

If one throws ten times, Gryt stops calling it and says so once:

✖ plugin watchdog failed 10 times and will not be called again: message.text is undefined

It stays off until you restart. That's deliberate — the alternative is a broken plugin writing a log line for every message forever.

Everybody can see what you're running

Every plugin you load is named to everybody who joins, along with who wrote it, what it's for, and what it's allowed to do. There's no way to turn that off and no flag to opt out.

That's deliberate. A server plugin reads the messages people send through your server, and the people sending them get to know what's reading them. If you'd rather they didn't, that's the situation this exists for.

What goes out is the manifest's id, name, author, description, homepage and capabilities. Not the version — a version number is which known problem applies, and handing it to everybody who joins answers a question an attacker would otherwise have to ask.

homepage has to be http or https, and anything else is dropped. Members are shown it as a link, so a javascript: URL in a manifest would be a link injection into their clients.

They read it in the server menu, under What this server runs — the same menu that has Leave in it, one row above. That's not an accident of ordering.

The capabilities are written out there, not shown as messages:read: "Reads every message you send in a channel", "Can kick you, ban you, and delete your messages". Somebody who's never read this page still gets to decide.

It arrives with the rest of the server details, which means people see it just after joining rather than before. Showing it before they join is worth doing and isn't done yet.

It also tells them what they're missing

If one of your plugins has a client half — anything with messaging — Gryt tells people who don't have it installed:

One of these has a part you do not have. Everything here works without them. You will just not see whatever they add.

Nobody gets turned away. A missing plugin means missing whatever it adds, not being refused: the messages and the voice are the server, and a plugin is what you put on top.

If you want people to be able to go and get it, put a homepage in the manifest. The row links to it.

Talking to a client plugin

A plugin can have a second half that runs in people's Gryt apps, and the two can send each other whatever they like. That's how a plugin adds something people can see rather than only something the server does quietly — a rich presence, a shared scoreboard, a poll.

It needs messaging, and it's its own page.

One thing worth knowing before enabling a plugin that asks for it: what arrives on that pipe was written by a member's client. They joined, which is worth less than it sounds. Gryt caps how big and how deeply nested a message is and refuses a handful of shapes built to break whatever handles them. Past that it's the plugin's job to check what it was sent, and a plugin that assumes its own client half is on the other end is a plugin that hasn't.

Plugins and addons

They're different things.

A client addon is a theme or a plugin somebody installs in their own Gryt app. It affects them and nobody else.

A server plugin is installed by whoever runs the server, and it affects everybody on it. That's worth remembering when you pick one — and so is the next section, because they'll all know which ones you picked.

On this page