Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/anti-spam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@meshcorejs/protocol": minor
"@meshcorejs/client": minor
"@meshcorejs/testing": patch
---

Anti-spam guards, on by default and not configurable:

- Commands ignore the Public channel unless they opt in with `setScope('public')` (`CommandScope` gains `'public'`); `channel.send()` and `message.reply()` on Public reject with `PublicChannelError`. `Channel.isPublic`, `PUBLIC_CHANNEL_SECRET`.
- On a channel every refusal is silent (`commandDenied` only) and `@Bot` answers one line (`Replies.helperChannel`). **Breaking:** `Replies.unknownCommandChannel` and `Replies.channelUntrusted` are removed.
- 10 parts per 5 minutes per channel (`CHANNEL_SEND_LIMIT`, `CHANNEL_SEND_WINDOW_MS`), one flood advert per 30 minutes (`ADVERT_FLOOD_INTERVAL_MS`); both reject with `RateLimitError`.
- `new Client({ maxHops })`: commands from further away are ignored (`commandDenied` `tooFar`), `message.reply()` rejects with `TooFarError`.
- `client.radio.request(code, payload, collect?)` sends an unwrapped Companion command through the request queue; the three mesh-transmitting commands are refused (`GuardedCommandError`). `expectType` and `Collector` are exported.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ new Client({ transport, replies: frenchReplies }); // or { unknownCommandDM: '
Holders of `Permissions.ManagePlugins` / `ManageJobs` (or `Administrator`) get `/plugins [load|unload|reload <name>]`
and `/jobs [run|pause|resume <name>]` in DM. Bots without roles expose nothing extra.

## Anti-spam by default

A bot built with meshcore.js cannot spam the mesh by accident:

- it never speaks first on **Public**, and only answers commands you allowed there with `setScope('public')`;
- on a channel it only speaks to answer a command it ran — refusals are silent;
- at most **10 messages per 5 minutes** on one channel, **one flood advert per 30 minutes**;
- `maxHops` bounds how far it answers.

None of this is configurable; see [A well-behaved bot](https://meshcore.js.org/docs/client/guides/a-well-behaved-bot).

## Requirements

- A MeshCore node running the **Companion Radio** firmware (protocol version 3 or newer).
Expand Down
1 change: 1 addition & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ cours = "cours"
sur = "sur"
allo = "allo"
trian = "trian"
commandes = "commandes"

[files]
extend-exclude = ["pnpm-lock.yaml", "dist", "coverage", "docs/superpowers", ".superpowers", "*.hex", "apps/docs/.next", "apps/docs/content/docs/*/reference", "apps/docs/next-env.d.ts"]
5 changes: 5 additions & 0 deletions apps/docs/content/docs/client/client/options-and-login.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ from the folders next to `main.ts`.
| `logger` | A `Logger`. Defaults to `createConsoleLogger()`. `silentLogger` disables logging. |
| `appName` | The name announced to the radio. Defaults to `meshcore.js`. |
| `load` | The folder scanned for bricks at login, usually `import.meta.dirname`. See [Bricks and loading](./bricks-and-loading). |
| `maxHops` | Bounds how far a message may have travelled for the bot to answer it. Integer ≥ 0; unset means no limit. See [A well-behaved bot](../guides/a-well-behaved-bot). |

## The login sequence

Expand All @@ -39,6 +40,10 @@ from the folders next to `main.ts`.
10. Start the jobs.
11. Emit `ready`.

Step 6 floods one advert when the name or location changed. That counts against the one-flood-per-30-minutes
limit (see [A well-behaved bot](../guides/a-well-behaved-bot)): if a flood already went out in the last 30
minutes, `login()` logs a warning and continues instead of failing.

## Caches and managers

| Property | Holds |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ they never collide with yours, and they are listed last in the helper.
## /help

A bare `/` or `/help` in a DM, a bare `@Bot` or `@Bot help` on a channel, list the commands the author
can use. One usage line per command, filtered by scope and, in a DM, by the author's permissions. A channel
listing only shows commands that need no permission. A long list is split into up to three parts.
can use, filtered by scope and, in a DM, by the author's permissions. A channel listing only shows commands
that need no permission. In a DM the answer is one usage line per command, split into up to three parts when
it is long; on a channel it is a single line with the command names (`helperChannel`), trimmed to fit the
budget.

## /plugins

Expand Down
21 changes: 12 additions & 9 deletions apps/docs/content/docs/client/commands/context-and-pipeline.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ The handler receives a `CommandContext`.
Every message that matches the trigger rule goes through these steps in order.

1. Trigger. Does it match a registered name or alias?
2. Scope. Is the command allowed in this DM or channel?
3. Age. Is a backlog message too old?
4. Permissions. Does the author hold every required permission?
5. Cooldown. Did the author run this command too recently?
6. Arguments. Do the values match the declaration?
7. Handler.

Every refusal emits `commandDenied` with a typed `DenyReason`. Scope and age refusals are silent. The
others answer with a text from `client.replies`.
2. Max hops. Is the message within `client.maxHops`, if one is set?
3. Scope. Is the command allowed in this DM or channel?
4. Age. Is a backlog message too old?
5. Permissions. Does the author hold every required permission?
6. Cooldown. Did the author run this command too recently?
7. Arguments. Do the values match the declaration?
8. Handler.

Every refusal emits `commandDenied` with a typed `DenyReason`. A message from beyond `maxHops` is denied
silently with `{ type: 'tooFar', hopCount }`, before the helper branch runs — so an out-of-range `@Bot` gets
nothing either. On a channel every refusal is silent — the bot only speaks to answer a command it actually
ran. In a DM the others answer with a text from `client.replies`.

## When the handler throws

Expand Down
69 changes: 69 additions & 0 deletions apps/docs/content/docs/client/guides/a-well-behaved-bot.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: A well-behaved bot
description: What the framework refuses by default so your bot never spams the mesh, and the knobs you have.
---

A channel message is a **flood** packet: every repeater that hears it relays it, whether it can read the channel
or not. Each reply your bot sends on a channel costs airtime to the whole mesh, and Public is the one channel
everybody listens to. meshcore.js enforces five rules so a bot cannot spam by accident.

## The five rules

1. **A bot never speaks first on Public**, and only answers the commands you explicitly allowed there, one by
one, with `setScope('public')`. `channel.send()` and `message.reply()` on Public reject with `PublicChannelError`.
2. **On a channel, the bot only speaks to answer a command it ran.** Unknown command, cooldown, missing
permission, bad argument: silent (you still get `commandDenied`). In DM, refusals are answered as before.
3. **At most 10 messages per 5 minutes on one channel.** Beyond that `channel.send()` / `ctx.reply()` reject with
`RateLimitError` — nothing is queued. DMs are not limited: they are routed, acknowledged and already bounded.
4. **One flood advert per 30 minutes** (`client.radio.sendSelfAdvert(true)`). Zero-hop adverts are free.
5. **You can bound how far your bot answers**: `new Client({ maxHops: 2 })` ignores commands and refuses
`message.reply()` beyond two hops (`Message.hopCount`, `Message.tooFar`).

These limits stop accidental spam (a runaway job, refusals in a loop, restarts) and remove every easy path.
They do not stop a determined actor: the code is open and a raw frame can be hand-built. Nothing in the API
justifies going around them — if you need a Companion command the library does not wrap, use
`client.radio.request()`, never write to `client.transport` yourself.

## Answer on Public, deliberately

```ts
import { CommandBuilder } from '@meshcorejs/client';

export default new CommandBuilder()
.setName('info')
.setDescription('What this bot does')
.setScope('public') // Public only; add 'dm', 'channel' for more
.setHandler((ctx) => ctx.reply('WeatherBot · DM me /help'));
```

`@Bot` alone on Public lists only the commands that allow `'public'`; with none, the bot stays silent.

## Prefer DMs for personal answers

A DM to a known contact is routed, not flooded. When the answer only concerns the author, use `ctx.replyDM()`
(the author must be a contact) and keep the channel reply to one short line — or none.

## Live on a regional channel

Hashtag channels derive their key from their name: anyone who knows `#fr-occ` can join. Put your bot's
commands on the channel of the people it serves rather than on Public. That chooses *who reads* the bot;
the rules above bound *what it transmits*.

## What you get when a limit hits

| Situation | Error / event |
|---|---|
| `channel.send()` or `message.reply()` on Public | `PublicChannelError` (`PUBLIC_CHANNEL`) |
| 11th part on a channel within 5 min | `RateLimitError` (`RATE_LIMIT`, `resource: 'channel'`, `retryAfterMs`) |
| Second flood advert within 30 min | `RateLimitError` (`resource: 'advert'`) |
| Reply to a message beyond `maxHops` | `TooFarError` (`TOO_FAR`); command → `commandDenied` `{ type: 'tooFar' }` |
| `radio.request()` with a transmitting command | `GuardedCommandError` (`GUARDED_COMMAND`) |

A command handler that lets `RateLimitError` escape does not get the usual `internalError` reply (it would be
refused too): the error is logged and `commandError` is emitted.

## Go further

- [Restrict who can run a command](./restrict-a-command) for scopes and permissions.
- [Post on a channel](./post-on-a-channel) for the send budgets and the per-channel limit.
- [Options and login](../client/options-and-login) for `maxHops` and the login flood advert.
1 change: 1 addition & 0 deletions apps/docs/content/docs/client/guides/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"answer-a-command",
"read-the-arguments",
"post-on-a-channel",
"a-well-behaved-bot",
"run-on-a-schedule",
"react-to-the-mesh",
"restrict-a-command",
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/content/docs/client/guides/post-on-a-channel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@ if the channel is missing instead of failing.
The message goes through the send queue. Transmissions are two seconds apart and the parts of one message
stay together.

<Callout type="warn">
A channel accepts at most **10 messages per 5 minutes**; beyond that `channel.send()` rejects with
`RateLimitError`, nothing is queued. Sending on the **Public** channel is refused outright with
`PublicChannelError` — see [A well-behaved bot](./a-well-behaved-bot).
</Callout>

## Go further

- [Sending messages](../client/sending-messages) for the budgets, the builder and delivery status.
- [Restrict a command](./restrict-a-command) so only owners can announce.
- [A well-behaved bot](./a-well-behaved-bot) for the channel rate limit and Public.
- [MessageBuilder](../reference/classes/MessageBuilder) in the reference.
14 changes: 12 additions & 2 deletions apps/docs/content/docs/client/guides/restrict-a-command.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ variable.
`setRequiredPermissions()` on the command is checked before the handler runs. Without the permission the
person gets `Permission denied`.

A channel author cannot be verified. A command with a required permission is refused on a channel and the
bot asks for a DM instead.
A channel author cannot be verified. A command with a required permission is refused on a channel, silently —
no reply on the air, only `commandDenied` with `channelUntrusted`. Run it in a DM instead. See
[Channels are untrusted](../permissions/channels-are-untrusted) for why.

## Scopes

`setScope()` takes one or more of `'dm' | 'channel' | 'public'` — where the command may be triggered. It
defaults to `'dm'` and `'channel'`: the firmware's Public channel is never included unless a command opts in
explicitly, `setScope('public')` (alongside the others if it should answer everywhere). See
[A well-behaved bot](./a-well-behaved-bot) for why Public is opt-in and what else the framework refuses by
default.

## Go further

- [Model and built-in permissions](../permissions/model-and-built-in-permissions) for `Permissions.*`.
- [Roles and members](../permissions/roles-and-members) for member sources and hierarchy.
- [A well-behaved bot](./a-well-behaved-bot) for scopes, rate limits and `maxHops`.
- [RoleBuilder](../reference/classes/RoleBuilder) in the reference.
24 changes: 24 additions & 0 deletions apps/docs/content/docs/client/guides/write-a-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,32 @@ loader registers it. The same plugin can be configured twice with two names.
`/plugins reload polls` from a DM rebuilds the bricks. If the new version fails, the previous bricks keep
running.

## A Companion command without a wrapper

`client.radio` only wraps the commands the library needs internally. For anything else, go through
`client.radio.request(code, payload?, collect?)` — never write frames to `client.transport` yourself, it
bypasses the one-request-in-flight queue. `CommandCode` is not re-exported by `@meshcorejs/client`; import it
from `@meshcorejs/protocol`.

```ts
import { CommandCode } from '@meshcorejs/protocol';
import { expectType } from '@meshcorejs/client';

const { epochSeconds } = await client.radio.request(
CommandCode.GetDeviceTime,
undefined,
expectType('currentTime', (f) => f),
);
```

The three transmitting commands the library knows (`SendTxtMsg`, `SendChannelTxtMsg`, `SendSelfAdvert`) are
refused with `GuardedCommandError`: use `channel.send()`, `contact.send()` or `client.radio.sendSelfAdvert()`
instead — the guarded surfaces that go through the send queue, carrying whichever limits apply (DMs aren't
rate-limited). See [A well-behaved bot](./a-well-behaved-bot).

## Go further

- [Writing one](../plugins/writing-one) for static bricks and validation.
- [Configuring and runtime](../plugins/configuring-and-runtime) for load, unload and reload.
- [Shipping a package](../plugins/shipping-a-package) to publish it on npm.
- [A well-behaved bot](./a-well-behaved-bot) for the guarded commands and the anti-spam limits.
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ A channel message carries a sender name in plain text and nothing that proves it
</Callout>

A command with `setRequiredPermissions()` is therefore always refused on a channel, whatever its scope.
The role check never runs. The bot answers with `channelUntrusted` and asks the person to send the command
in a DM, where the sender is a verified contact.
The role check never runs. The refusal is silent: `commandDenied` fires with `channelUntrusted`, but the bot
says nothing on the air — on a channel every refusal is silent, only a DM gets an answer from
`client.replies`. Send the command in a DM instead, where the sender is a verified contact.

The same rule applies to the built-in helper. On a channel it only lists the commands that need no
permission.
The same rule applies to the built-in helper. On a channel its one line (`helperChannel`) only lists the
commands that need no permission.

`ctx.author.verified` tells a handler which case it is in. `ctx.replyDM()` rejects for an unverified
author.
Expand Down
3 changes: 1 addition & 2 deletions apps/docs/content/docs/client/replies/all-replies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ description: Every key of client.replies and its English default.
| Key | English default |
| --------------------------------- | ------------------------------------------------------------------------------------- |
| `unknownCommandDM` | `❓ Unknown command, /help` |
| `unknownCommandChannel` | `❓ Unknown command` |
| `noCommands` | `No commands available` |
| `internalError` | `❌ Internal error` |
| `channelUntrusted` | `↪️ Send me this command in a DM` |
| `missingPermissions` | `⛔ Permission denied` |
| `cooldown(seconds)` | `⏳ Try again in ${seconds}s` |
| `invalidArgument(name, usage)` | `⚠️ ${name} is invalid\n${usage}` |
| `missingArgument(name, usage)` | `⚠️ ${name} is missing\n${usage}` |
| `tooManyArguments(usage)` | `⚠️ too many arguments\n${usage}` |
| `usage(usage)` | `⚠️ usage\n${usage}` |
| `helperChannel(names)` | `Commands: ${names.join(', ')} · DM me /help` |
| `noPlugins` | `No plugins` |
| `pluginLine(name, loaded, count)` | `${name} ✅ ${count}` when loaded, `${name} ⏸️` when unloaded |
| `pluginLoaded(name, count)` | `✅ ${name} loaded (${count})` |
Expand Down
Loading