# Plugins

Source: https://docs.gryt.chat/docs/server/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.

<Callout type="warn">
**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.
</Callout>

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](https://docs.gryt.chat/docs/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:

```bash
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`:

```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`:

```js
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:

| Event | When | Needs |
| --- | --- | --- |
| `message:created` | Somebody sends a message in a channel | `messages:read` |
| `member:joined` | Somebody joins for the first time, or comes back after a kick | `members:read` |
| `member:left` | Somebody leaves, is kicked, or is banned | `members:read` |

And one thing it can do, covered below:

| Action | What it does | Needs |
| --- | --- | --- |
| `api.moderation.kick` / `.ban` | Removes a member, not a moderator | `moderation` |
| `api.moderation.deleteMessage` | Takes a post down | `moderation` |

`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:

```js
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:

```js
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:

| Capability | What it lets a plugin do |
| --- | --- |
| `messages:read` | Read every message sent in a channel |
| `members:read` | See who joins and leaves, and the invite code they used |
| `moderation` | Kick and ban members and delete their messages, but not a moderator&rsquo;s |
| `messaging` | Exchange its own messages with the copy of itself in people&rsquo;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

<Steps>

<Step>
### Make the folder

```bash
mkdir -p /srv/gryt/plugins/watchdog
```

</Step>

<Step>
### `manifest.json`

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

</Step>

<Step>
### `index.mjs`

```js
// 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);
  });
}
```

</Step>

<Step>
### Restart

```bash
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
```

</Step>

</Steps>

## 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.

<Callout>
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.
</Callout>

### 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](https://docs.gryt.chat/docs/guide/plugin-pairs).

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](https://docs.gryt.chat/docs/client/addons) 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.
