From d41742f68bea30fd9b243d62c3e4827587e2491a Mon Sep 17 00:00:00 2001 From: theolagache <43389096+Asuniia@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:04:38 +0200 Subject: [PATCH 1/3] feat!: anti-spam A bot cannot spam the mesh by accident any more: - commands ignore the Public channel unless they opt in with setScope('public'); channel.send() and message.reply() there reject with PublicChannelError - on a channel every refusal is silent (commandDenied only) and @Bot answers one line - 10 parts / 5 min per channel and one flood adve refused with RateLimitError - new Client({ maxHops }) bounds how far the bot - client.radio.request() is the door for unwrapped Companion commands; the three transmitting ones are refused (Guarde BREAKING CHANGE: Replies.unknownCommandChannel an Replies.channelUntrusted are removed; commands no longer answer on the Public channel by default. --- .changeset/anti-spam.md | 13 ++ README.md | 11 ++ .../docs/client/client/options-and-login.mdx | 5 + .../client/commands/built-in-commands.mdx | 6 +- .../client/commands/context-and-pipeline.mdx | 21 +-- .../docs/client/guides/a-well-behaved-bot.mdx | 69 +++++++++ .../docs/content/docs/client/guides/meta.json | 1 + .../docs/client/guides/post-on-a-channel.mdx | 7 + .../docs/client/guides/restrict-a-command.mdx | 14 +- .../docs/client/guides/write-a-plugin.mdx | 24 ++++ .../permissions/channels-are-untrusted.mdx | 9 +- .../docs/client/replies/all-replies.mdx | 3 +- packages/client/AGENTS.md | 76 ++++++++-- packages/client/src/client/client.ts | 19 ++- .../client/src/commands/command-builder.ts | 2 +- .../client/src/commands/command-manager.ts | 73 +++++++--- packages/client/src/commands/command.ts | 13 +- packages/client/src/commands/context.ts | 3 +- packages/client/src/commands/replies.ts | 10 +- packages/client/src/errors.ts | 92 ++++++++++++ packages/client/src/index.ts | 10 +- packages/client/src/messages/send-queue.ts | 46 +++++- packages/client/src/radio/radio.ts | 90 +++++++++++- packages/client/src/radio/request-queue.ts | 4 + packages/client/src/structures/channel.ts | 10 +- packages/client/src/structures/message.ts | 14 ++ packages/client/test/advert-limit.test.ts | 106 ++++++++++++++ packages/client/test/channel-silence.test.ts | 106 ++++++++++++++ packages/client/test/channels.test.ts | 17 ++- .../client/test/command-permissions.test.ts | 6 +- packages/client/test/commands.test.ts | 43 +++++- packages/client/test/helpers.ts | 10 +- packages/client/test/index.test.ts | 8 ++ packages/client/test/max-hops.test.ts | 101 +++++++++++++ packages/client/test/public-channel.test.ts | 136 ++++++++++++++++++ packages/client/test/radio-request.test.ts | 49 +++++++ packages/client/test/replies.test.ts | 2 +- packages/client/test/send-queue.test.ts | 78 +++++++++- packages/protocol/AGENTS.md | 3 +- packages/protocol/src/constants.ts | 7 + packages/protocol/test/index.test.ts | 1 + packages/protocol/test/public-channel.test.ts | 12 ++ packages/testing/AGENTS.md | 1 + .../test/fixtures/bot/commands/info.ts | 7 + packages/testing/test/test-client.test.ts | 13 +- packages/transports/AGENTS.md | 6 +- packages/transports/src/mock/fake-radio.ts | 2 + 47 files changed, 1269 insertions(+), 90 deletions(-) create mode 100644 .changeset/anti-spam.md create mode 100644 apps/docs/content/docs/client/guides/a-well-behaved-bot.mdx create mode 100644 packages/client/test/advert-limit.test.ts create mode 100644 packages/client/test/channel-silence.test.ts create mode 100644 packages/client/test/max-hops.test.ts create mode 100644 packages/client/test/public-channel.test.ts create mode 100644 packages/client/test/radio-request.test.ts create mode 100644 packages/protocol/test/public-channel.test.ts create mode 100644 packages/testing/test/fixtures/bot/commands/info.ts diff --git a/.changeset/anti-spam.md b/.changeset/anti-spam.md new file mode 100644 index 0000000..3290321 --- /dev/null +++ b/.changeset/anti-spam.md @@ -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. diff --git a/README.md b/README.md index f4a40a4..7167f1a 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,17 @@ new Client({ transport, replies: frenchReplies }); // or { unknownCommandDM: ' Holders of `Permissions.ManagePlugins` / `ManageJobs` (or `Administrator`) get `/plugins [load|unload|reload ]` and `/jobs [run|pause|resume ]` 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). diff --git a/apps/docs/content/docs/client/client/options-and-login.mdx b/apps/docs/content/docs/client/client/options-and-login.mdx index e6a533b..1112abe 100644 --- a/apps/docs/content/docs/client/client/options-and-login.mdx +++ b/apps/docs/content/docs/client/client/options-and-login.mdx @@ -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 @@ -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 | diff --git a/apps/docs/content/docs/client/commands/built-in-commands.mdx b/apps/docs/content/docs/client/commands/built-in-commands.mdx index bcc17c1..dd9aaf6 100644 --- a/apps/docs/content/docs/client/commands/built-in-commands.mdx +++ b/apps/docs/content/docs/client/commands/built-in-commands.mdx @@ -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 diff --git a/apps/docs/content/docs/client/commands/context-and-pipeline.mdx b/apps/docs/content/docs/client/commands/context-and-pipeline.mdx index 28ffb02..c13be73 100644 --- a/apps/docs/content/docs/client/commands/context-and-pipeline.mdx +++ b/apps/docs/content/docs/client/commands/context-and-pipeline.mdx @@ -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 diff --git a/apps/docs/content/docs/client/guides/a-well-behaved-bot.mdx b/apps/docs/content/docs/client/guides/a-well-behaved-bot.mdx new file mode 100644 index 0000000..c728b52 --- /dev/null +++ b/apps/docs/content/docs/client/guides/a-well-behaved-bot.mdx @@ -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. diff --git a/apps/docs/content/docs/client/guides/meta.json b/apps/docs/content/docs/client/guides/meta.json index 76ff9a4..17306a1 100644 --- a/apps/docs/content/docs/client/guides/meta.json +++ b/apps/docs/content/docs/client/guides/meta.json @@ -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", diff --git a/apps/docs/content/docs/client/guides/post-on-a-channel.mdx b/apps/docs/content/docs/client/guides/post-on-a-channel.mdx index 49b4515..fe0550d 100644 --- a/apps/docs/content/docs/client/guides/post-on-a-channel.mdx +++ b/apps/docs/content/docs/client/guides/post-on-a-channel.mdx @@ -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. + +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). + + ## 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. diff --git a/apps/docs/content/docs/client/guides/restrict-a-command.mdx b/apps/docs/content/docs/client/guides/restrict-a-command.mdx index 352d5a9..4e8efa9 100644 --- a/apps/docs/content/docs/client/guides/restrict-a-command.mdx +++ b/apps/docs/content/docs/client/guides/restrict-a-command.mdx @@ -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. diff --git a/apps/docs/content/docs/client/guides/write-a-plugin.mdx b/apps/docs/content/docs/client/guides/write-a-plugin.mdx index 9b84460..91b3604 100644 --- a/apps/docs/content/docs/client/guides/write-a-plugin.mdx +++ b/apps/docs/content/docs/client/guides/write-a-plugin.mdx @@ -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. diff --git a/apps/docs/content/docs/client/permissions/channels-are-untrusted.mdx b/apps/docs/content/docs/client/permissions/channels-are-untrusted.mdx index 8449cd8..49afbc2 100644 --- a/apps/docs/content/docs/client/permissions/channels-are-untrusted.mdx +++ b/apps/docs/content/docs/client/permissions/channels-are-untrusted.mdx @@ -8,11 +8,12 @@ A channel message carries a sender name in plain text and nothing that proves it 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. diff --git a/apps/docs/content/docs/client/replies/all-replies.mdx b/apps/docs/content/docs/client/replies/all-replies.mdx index 123a138..9eb65b2 100644 --- a/apps/docs/content/docs/client/replies/all-replies.mdx +++ b/apps/docs/content/docs/client/replies/all-replies.mdx @@ -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})` | diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 4e134f1..f491842 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -35,7 +35,7 @@ src/ managers/ contact-manager.ts ContactManager (cache, get by key/prefix, fetch, remove, resetPath), Advert channel-manager.ts ChannelManager (cache, get by name/index, create, delete), hashtagChannelSecret - structures/ Contact, Channel, Message (author: Contact | UnverifiedAuthor, backlog, snr…) + structures/ Contact, Channel (index, name, secret, isHashtag, isPublic, send), Message (author, backlog, snr, hopCount, tooFar…) messages/ send-queue.ts SendQueue: 2 s pacing, DM ACK wait, resends, RESET_PATH + flood, 5 min expiry sent-message.ts SentMessage { status, delivered() } @@ -44,7 +44,7 @@ src/ commands/ command-builder.ts CommandBuilder({ core? }), COMMAND_NAME_PATTERN, RESERVED_COMMAND_NAMES ['help', 'plugins', 'jobs'], DEFAULT_MAX_AGE_SECONDS args.ts ArgBuilder + typed arg definitions (string, integer, number, boolean, choice, contact, role, channel) - command.ts Command (built definition + precomputed usages) + command.ts Command (built definition + precomputed usages), scopeOf(message) (internal) trigger.ts the trigger rule: '/name' in DM (or '@Bot name'), '@Bot name' on channels tokenize.ts / parse-args.ts whitespace + double-quote tokenizer, ArgumentError command-manager.ts pipeline on messageCreate, cooldowns, built-in helper (HELPER_MAX_PARTS = 3); core commands listed last @@ -70,16 +70,23 @@ test/ ## Public API -- `new Client({ transport, radio?: RadioConfig, replies?: Partial, logger?, appName? = 'meshcore.js', load? })` +- `new Client({ transport, radio?: RadioConfig, replies?: Partial, logger?, appName? = 'meshcore.js', load?, maxHops? })` — infrastructure only, no bot content. `client.replies` is the merge of `englishReplies` and the override; `frenchReplies` is a complete French preset (`replies: frenchReplies`). `client.register(brick | brick[])`, `client.load(dir, { watch? })`, `client.login()`, `client.destroy()`, `client.status`, `client.isReady`, `client.self`, `client.device` (throw - `ClientStateError` before login), `client.radioConfig`. + `ClientStateError` before login), `client.radioConfig`, `client.maxHops: number | null` (validated: integer + ≥ 0, `RangeError` otherwise; `null` means no limit). - Managers: `client.contacts`, `client.channels`, `client.commands`, `client.events`, `client.jobs`, `client.permissions`, `client.roles`, `client.sendQueue`, `client.radio` (typed Companion commands, e.g. `sendSelfAdvert(flood)`, `exportSelfContact()`), `client.logger`, `client.transport`. - `client.radio.setAdvertName / setAdvertLatLon / setTxPower / setRadioParams` — app units (MHz, kHz, dBm, degrees), validated (`RangeError`), radio refusal → `RadioError`. +- `client.radio.request(code, payload?: Uint8Array, collect?: Collector)` — the door for a + Companion command without a typed wrapper: `code` a byte (`RangeError` otherwise), `payload` the command body + without the code byte (default empty), `collect` turns response frames into `T` (default expects `Ok`). + `expectType(type, map)` builds a `Collector` that resolves on the first frame of `type`. Refused + (`GuardedCommandError`) for `SendTxtMsg`, `SendChannelTxtMsg`, `SendSelfAdvert`. Both `expectType` and + `Collector` are exported for this. - Events (`ClientEvents`): `ready`, `disconnect`, `reconnecting`, `error(error, source)`, `messageCreate`, `messageDelivered`, `messageFailed`, `advert`, `contactAdd|Update|Remove`, `contactsFull`, `channelUpdate`, `commandRun`, `commandError`, `commandDenied(ctx, reason)`, `pluginLoad`, `pluginUnload`, `raw`. `ErrorSource.type` @@ -101,11 +108,16 @@ test/ MessageBuilder` and resolve to `SentMessage`; `MessageBuilder` (`setOverflow`, `measure`, `build(budget)`). - Errors: `MeshcoreError { code }` → `LoadError`, `ConnectionError`, `ClientStateError`, `UnsupportedFirmwareError`, `JobTimeoutError`, `MissingDependencyError`, `CommandTimeoutError`, `RadioError`, - `RadioConfigError`, `LimitReachedError`, `MessageTooLongError`, `DeliveryFailedError`. + `RadioConfigError`, `LimitReachedError`, `MessageTooLongError`, `DeliveryFailedError`, `PublicChannelError`, + `RateLimitError` (`resource: RateLimitedResource`, `channel`, `limit`, `windowMs`, `retryAfterMs`), `TooFarError` + (`hopCount`, `maxHops`) — thrown by `message.reply()` / `ctx.reply()` when `message.tooFar` is true. + `GuardedCommandError` (`commandCode`) — thrown by `client.radio.request()` for the three commands that + transmit on the mesh. - Tuning constants are exported but not configurable: `SEND_INTERVAL_MS = 2000`, `DM_MAX_RESENDS = 3`, `SEND_EXPIRY_MS = 5 min`, `RECONNECT_BASE_DELAY_MS = 1 s`, `RECONNECT_MAX_DELAY_MS = 60 s`, `HEALTH_CHECK_INTERVAL_MS = 60 s`, `HEALTH_CHECK_MAX_FAILURES = 2`, `DESTROY_FLUSH_TIMEOUT_MS = 5 s`, - `DEFAULT_MAX_PARTS = 3`. + `DEFAULT_MAX_PARTS = 3`, `CHANNEL_SEND_LIMIT = 10`, `CHANNEL_SEND_WINDOW_MS = 5 min`, + `ADVERT_FLOOD_INTERVAL_MS = 30 min`. ## Rules @@ -132,13 +144,25 @@ Commands - Trigger rule is fixed: DM `/name` (no space after `/`) — the channel form is tolerated in DM too —, channel `@Bot name` (`@[Name]` or `@Name`, case-insensitive, never `/`). Anything else is only `messageCreate`. Do not add options or hooks to change it. -- Pipeline order: trigger → scope → maxAge (backlog) → permissions → cooldown → argument parsing → handler. - Every refusal emits `commandDenied` with a typed `DenyReason`; `backlog` and `scope` are silent, the others - answer with `client.replies` — **English by default**, `frenchReplies` preset, partial override. Only texts are +- Scopes (`CommandScope`): `dm | channel | public`. `setScope()` defaults to `dm` and `channel`; the firmware's + Public channel (`Channel.isPublic`) is never included unless a command opts in with `setScope('public')` (or + lists it alongside the others). `scopeOf(message)` (internal, `commands/command.ts`) maps a received message + to its scope and drives both the pipeline and the helper. +- Pipeline order: trigger → maxHops → scope → maxAge → permissions → cooldown → args → handler. A message from + beyond `client.maxHops` (`Message.hopCount`) is dropped silently, before the helper branch, so a `@Bot` from + too far gets nothing either. Every refusal emits `commandDenied` with a typed `DenyReason`; **on a channel + every refusal is silent** + (`commandDenied` only — the bot only speaks to answer a command it executed); only a DM gets a text from + `client.replies` — **English by default**, `frenchReplies` preset, partial override. Only texts are configurable: command names and actions (`help`, `plugins`, `jobs`, `load`, `unload`, `reload`, `run`, `pause`, - `resume`) stay English. -- Built-in helper (`/`, `/help`, `@Bot`, `@Bot help`): one syntax line per command, filtered by scope and - (in DM) by the author's permissions; channel lists only permission-free commands. + `resume`) stay English. **Breaking:** `Replies.unknownCommandChannel` and `Replies.channelUntrusted` are + removed — every channel refusal is silent now — and `Replies.helperChannel(names)` replaces them for the + one line the bot may send on a channel. +- Built-in helper (`/`, `/help`, `@Bot`, `@Bot help`): filtered by scope and (in DM) by the author's + permissions; channel lists only permission-free commands. In a DM, one syntax line per command. On a + channel, one line (`Replies.helperChannel(names)`) with the usable command names, trimmed from the end + (marked with `…`) to fit the channel's byte budget. When nothing is usable there, the bot answers + `noCommands` on an ordinary channel but stays silent on Public. - Reserved names: `help`, `plugins`, `jobs`. The built-in `/plugins [load|unload|reload ]` and `/jobs [run|pause|resume ]` are ordinary `CommandBuilder({ core: true })`s registered by the `Client` constructor (`commands/core/`), listed after user commands in the helper, DM-only through their required @@ -157,7 +181,16 @@ Sending - `SendQueue` is the only path to the air: 2 s between transmissions, parts of one message contiguous, DM ACK on `SEND_CONFIRMED` matching `SENT.expected_ack`, up to 3 resends then `RESET_PATH` + one flood, then `messageFailed`. Channel sends resolve immediately (`status: 'sent'`). +- `Radio.sendText()` and `Radio.sendChannelText()` are `@internal` (excluded from the reference) and only + called by `SendQueue`: they carry no guard at all. `contact.send()` / `channel.send()` / `SendQueue` are the + only path to the air; `Radio.sendSelfAdvert()` stays public because it is itself guarded (see below). - No splitting inside a code point or an emoji ZWJ sequence (`Intl.Segmenter`). +- Public: `SendQueue.send()` refuses a Public target unless the `@internal` `allowPublic` option is set, which + only `CommandContext.reply()` and the command manager's `#safeReply` set; `channel.send()` / `message.reply()` + there reject with `PublicChannelError`. +- Rate limit: 10 parts / 5 min sliding window per channel, counted at acceptance, a message that does not fit + whole is refused whole; DMs are not limited; a `RateLimitError` escaping a handler is logged, not answered on + the air. Client lifecycle @@ -170,9 +203,18 @@ Client lifecycle `client.self` is the radio's truth, and one flood advert if name or location changed. A refusal is a `RadioConfigError` that fails `login()`; during reconnection it is retried with the backoff. `repeat` is passed to `SET_RADIO_PARAMS` only on firmware ≥ 9, with the device's current value. +- `Radio.sendSelfAdvert(true)` (flood) is limited to one per `ADVERT_FLOOD_INTERVAL_MS` + (`RateLimitError { resource: 'advert' }` otherwise); zero-hop adverts (`sendSelfAdvert(false)`) are always + free. `login()`'s identity-changed advert counts against that window; when it is refused, `login()` logs a + warning instead of failing. The window it reserves is only rolled back on a `RadioError` (the firmware + answered `Err`, so nothing went out); a timeout or any other failure keeps it consumed, since a lost reply + does not prove the flood was never sent and the reconnect loop would otherwise retry it every flap. - Transports never reconnect; `Client` does (exponential backoff 1 s → 60 s, unlimited, health ping every 60 s, 2 failures). After reconnection the cache is reconciled and only differences emit events. - One Companion command in flight at a time (`RequestQueue`); pushes (`0x80+`) bypass the queue. +- `radio.request()` is the door for Companion commands without a wrapper; `SendTxtMsg`, `SendChannelTxtMsg` + and `SendSelfAdvert` are refused there (`GuardedCommandError`) because they transmit on the mesh; never + write raw frames to `client.transport`. - Error policy: only `load()`, `login()` and `plugin.load()/unload()/reload()` throw. Handler errors → `commandError` + `Replies.internalError`; event/job errors → `error` with `source.type`; without an `error` listener, log and never crash. - `client.load()` scans `permissions/ roles/ commands/ events/ jobs/ plugins/` in that order, recursively, @@ -185,11 +227,15 @@ Client lifecycle - `pnpm vitest run packages/client`. Use `setupClient()` from `test/helpers.ts` (`MockTransport` + `FakeRadio`, `silentLogger`) and Vitest fake timers (`vi.useFakeTimers()`, `flush()`) for anything that - waits: send pacing, ACK timeouts, backoff, cooldowns, jobs. + waits: send pacing, ACK timeouts, backoff, cooldowns, jobs, the channel and advert rate limits. + `setupClient({ maxHops })` sets the client's limit for hop-related tests. - Drive the radio side with `radio.receiveContactMessage()`, `radio.receiveChannelMessage()`, `radio.ack()`, `radio.ackMode = 'never'`, `radio.unresponsive = true`, `transport.simulateDisconnect()`. -- Assert what went on the air through `radio.sent` (`wireText` for channels), and refusals through - `commandDenied`. +- Assert what went on the air through `radio.sent` (`wireText` for channels), refusals through + `commandDenied`, and Companion commands issued (e.g. to check `radio.request()` or the flood-advert guard) + through `radio.commands` (the codes sent, in order). +- A channel fixture needs `PUBLIC_CHANNEL_SECRET` (from `@meshcorejs/protocol`) as its `secret` to be seen as + Public (`Channel.isPublic`); any other secret is an ordinary channel. - Bot-level scenarios (a whole `load()` directory, simulated calendar time) belong in `@meshcorejs/testing` tests or the examples, not here. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b526cf7..9a8c3f8 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -18,6 +18,7 @@ import { LoadError, RadioConfigError, RadioError, + RateLimitError, UnsupportedFirmwareError, } from '../errors.js'; import { EventManager } from '../events/event-manager.js'; @@ -37,7 +38,7 @@ import type { ClientEvents, ErrorSource } from './events.js'; import { BrickLoader, type LoadOptions } from './loader.js'; import { BrickRegistry } from './registry.js'; -/** Infrastructure for a `Client`: transport, optional radio config, replies override, logger, app name, bricks dir. */ +/** Infrastructure for a `Client`: transport, optional radio config, replies override, logger, app name, bricks dir, max hops. */ export interface ClientOptions { transport: Transport; radio?: RadioConfig; @@ -45,6 +46,8 @@ export interface ClientOptions { logger?: Logger; appName?: string; load?: string | URL; + /** Ignore commands and refuse `message.reply()` beyond this many hops (`Message.hopCount`). Default: no limit */ + maxHops?: number; } /** Lifecycle state of a `Client`, from construction to `destroy()`. */ @@ -81,6 +84,7 @@ export class Client extends TypedEmitter { readonly plugins: PluginManager; readonly replies: Replies; readonly radioConfig: RadioConfig | null; + readonly maxHops: number | null; /** @internal */ readonly registry: BrickRegistry; readonly #appName: string; @@ -103,6 +107,10 @@ export class Client extends TypedEmitter { this.logger = options.logger ?? createConsoleLogger(); this.#appName = options.appName ?? 'meshcore.js'; this.replies = mergeReplies(options.replies); + if (options.maxHops !== undefined && (!Number.isInteger(options.maxHops) || options.maxHops < 0)) { + throw new RangeError(`maxHops must be an integer >= 0, got ${options.maxHops}`); + } + this.maxHops = options.maxHops ?? null; this.radio = new Radio(this.transport); this.contacts = new ContactManager(this); this.channels = new ChannelManager(this); @@ -253,8 +261,13 @@ export class Client extends TypedEmitter { } this.#self = await this.radio.appStart(this.#appName); if (changes.includes('name') || changes.includes('location')) { - await this.radio.sendSelfAdvert(true); - this.logger.info('radio: advert sent (identity changed)'); + try { + await this.radio.sendSelfAdvert(true); + this.logger.info('radio: advert sent (identity changed)'); + } catch (error) { + if (!(error instanceof RateLimitError)) throw error; + this.logger.warn(`radio: flood advert skipped, ${error.message}`); + } } } diff --git a/packages/client/src/commands/command-builder.ts b/packages/client/src/commands/command-builder.ts index 9867bcc..0ae2f4e 100644 --- a/packages/client/src/commands/command-builder.ts +++ b/packages/client/src/commands/command-builder.ts @@ -127,7 +127,7 @@ export class CommandBuilder implements Brick { const trigger = parseTrigger(message.content, message.isDM, this.client.self.name); if (!trigger) return; + if (message.tooFar) { + await this.#deny(message, null, { type: 'tooFar', hopCount: message.hopCount as number }); + return; + } if (trigger.type === 'helper') { await this.#sendHelper(message); return; @@ -110,11 +117,11 @@ export class CommandManager { message, null, { type: 'unknownCommand', name: trigger.name }, - message.isDM ? this.client.replies.unknownCommandDM : this.client.replies.unknownCommandChannel, + this.client.replies.unknownCommandDM, ); return; } - if (!command.allows(message.isDM ? 'dm' : 'channel')) { + if (!command.allows(scopeOf(message))) { await this.#deny(message, command, { type: 'scope' }); return; } @@ -126,7 +133,7 @@ export class CommandManager { if (command.requiredPermissions.length > 0) { if (!message.isDM) { - await this.#deny(message, command, { type: 'channelUntrusted' }, this.client.replies.channelUntrusted); + await this.#deny(message, command, { type: 'channelUntrusted' }); return; } let missing: Permission[]; @@ -188,14 +195,18 @@ export class CommandManager { if (this.client.listenerCount('commandError') > 0) this.client.emit('commandError', error instanceof Error ? error : new Error(String(error)), ctx); else this.client._reportError(error, { type: 'command', name: command.name }); + if (error instanceof RateLimitError && error.resource === 'channel') { + this.client.logger.warn(`command ${command.name}: ${error.message}`); + return; + } const reply = command.definition.errorHandler?.(ctx, error) ?? this.client.replies.internalError; await this.#safeReply(message, reply); } } async #sendHelper(message: Message): Promise { - const scope = message.isDM ? 'dm' : 'channel'; - const lines: string[] = []; + const scope = scopeOf(message); + const usable: Command[] = []; for (const command of this.cache.values()) { if (!command.allows(scope)) continue; if (command.requiredPermissions.length > 0) { @@ -203,13 +214,42 @@ export class CommandManager { const missing = await this.client.permissions.missing(message.author, [...command.requiredPermissions]); if (missing.length > 0) continue; } - lines.push(formatUsage(command, message.isDM, this.client.self.name)); + usable.push(command); + } + if (message.isDM) { + const lines = usable.map((command) => formatUsage(command, true, this.client.self.name)); + const reply = + lines.length === 0 + ? this.client.replies.noCommands + : new MessageBuilder().addLines(lines).setOverflow('split', { maxParts: HELPER_MAX_PARTS }); + await this.#safeReply(message, reply); + return; + } + if (usable.length === 0) { + if (scope !== 'public') await this.#safeReply(message, this.client.replies.noCommands); + return; + } + await this.#safeReply( + message, + this.#channelHelperLine( + message, + usable.map((c) => c.name), + ), + ); + } + + /** One line that fits the channel budget: drops names from the end and marks the cut with `…`. */ + #channelHelperLine(message: Message, names: string[]): string { + const channel = message.channel as Channel; + const prefix = message.author.name === '' ? '' : mentionPrefix(message.author.name); + const budget = this.client.sendQueue.budgetFor(channel, prefix); + let shown = names.length; + let line = this.client.replies.helperChannel(names); + while (utf8ByteLength(line) > budget && shown > 1) { + shown--; + line = this.client.replies.helperChannel([...names.slice(0, shown), '…']); } - const reply = - lines.length === 0 - ? this.client.replies.noCommands - : new MessageBuilder().addLines(lines).setOverflow('split', { maxParts: HELPER_MAX_PARTS }); - await this.#safeReply(message, reply); + return line; } async #deny(message: Message, command: Command | null, reason: DenyReason, reply?: MessageContent): Promise { @@ -222,14 +262,15 @@ export class CommandManager { command, }; this.client.emit('commandDenied', ctx, reason); - if (reply !== undefined) await this.#safeReply(message, reply); + if (reply !== undefined && message.isDM) await this.#safeReply(message, reply); } async #safeReply(message: Message, content: MessageContent): Promise { try { - await message.reply(content); + await message._reply(content, {}, true); } catch (error) { - this.client._reportError(error, { type: 'internal', name: 'commands' }); + if (error instanceof RateLimitError) this.client.logger.warn(`commands: ${error.message}`); + else this.client._reportError(error, { type: 'internal', name: 'commands' }); } } } diff --git a/packages/client/src/commands/command.ts b/packages/client/src/commands/command.ts index 8724909..fd568d0 100644 --- a/packages/client/src/commands/command.ts +++ b/packages/client/src/commands/command.ts @@ -1,10 +1,11 @@ import type { MessageBuilder } from '../builders/message-builder.js'; +import type { Message } from '../structures/message.js'; import type { ArgDefinition } from './args.js'; import type { CommandContext } from './context.js'; import type { ArgumentError } from './parse-args.js'; -/** Where a command may be triggered: direct message, channel, or both. */ -export type CommandScope = 'dm' | 'channel'; + +export type CommandScope = 'dm' | 'channel' | 'public'; /** `CommandBuilder.setHandler()`'s callback, run once trigger, scope, permissions and cooldown pass. */ // biome-ignore lint/suspicious/noExplicitAny: handlers are type-erased @@ -60,7 +61,7 @@ export class Command { this.core = definition.core; } - /** @param scope dm or channel */ + /** @param scope dm, channel or public */ allows(scope: CommandScope): boolean { return this.scopes.includes(scope); } @@ -71,3 +72,9 @@ export function formatUsage(command: Command, isDM: boolean, botName: string): s const head = isDM ? `/${command.name}` : `@${botName} ${command.name}`; return [head, ...args].join(' '); } + +/** The scope a received message falls in: `dm`, `public` (the firmware's Public channel) or `channel`. */ +export function scopeOf(message: Message): CommandScope { + if (message.channel === null) return 'dm'; + return message.channel.isPublic ? 'public' : 'channel'; +} diff --git a/packages/client/src/commands/context.ts b/packages/client/src/commands/context.ts index b2eb0aa..3a45e9d 100644 --- a/packages/client/src/commands/context.ts +++ b/packages/client/src/commands/context.ts @@ -14,6 +14,7 @@ import type { Command } from './command.js'; /** Why a command was refused; every refusal emits `commandDenied` with one of these. */ export type DenyReason = | { type: 'unknownCommand'; name: string } + | { type: 'tooFar'; hopCount: number } | { type: 'scope' } | { type: 'backlog'; ageSeconds: number } | { type: 'channelUntrusted' } @@ -76,7 +77,7 @@ export class CommandContext> { * @param options mention prefixes the author on a channel. Default true */ reply(content: MessageContent, options: { mention?: boolean } = {}): Promise { - return this.message.reply(content, options); + return this.message._reply(content, options, true); } /** @param permission Permission to check for the author */ diff --git a/packages/client/src/commands/replies.ts b/packages/client/src/commands/replies.ts index e629b23..549fcea 100644 --- a/packages/client/src/commands/replies.ts +++ b/packages/client/src/commands/replies.ts @@ -1,10 +1,8 @@ /** Every text the bot says on its own: refusals, usage errors, and the answers of the built-in `help`, `plugins` and `jobs` commands. Functions receive what they need to format the reply. */ export interface Replies { unknownCommandDM: string; - unknownCommandChannel: string; noCommands: string; internalError: string; - channelUntrusted: string; missingPermissions: string; /** @param seconds Seconds left */ cooldown(seconds: number): string; @@ -22,6 +20,8 @@ export interface Replies { tooManyArguments(usage: string): string; /** @param usage Command usage line */ usage(usage: string): string; + /** One-line helper on a channel: the command names usable there. `names` is never empty. */ + helperChannel(names: string[]): string; noPlugins: string; /** * @param name Plugin name @@ -83,16 +83,15 @@ const pluginLine: Replies['pluginLine'] = (name, loaded, count) => (loaded ? `${ /** The default replies, in English. */ export const englishReplies: Replies = Object.freeze({ 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, pluginLoaded: (name, count) => `✅ ${name} loaded (${count})`, @@ -116,16 +115,15 @@ export const englishReplies: Replies = Object.freeze({ /** A complete French set of replies, to pass as `replies` to the `Client`. */ export const frenchReplies: Replies = Object.freeze({ unknownCommandDM: '❓ Commande inconnue, /help', - unknownCommandChannel: '❓ Commande inconnue', noCommands: 'Aucune commande disponible', internalError: '❌ Erreur interne', - channelUntrusted: '↪️ Envoie-moi cette commande en DM', missingPermissions: '⛔ Permission refusée', cooldown: (seconds) => `⏳ Réessaie dans ${seconds}s`, invalidArgument: (name, usage) => `⚠️ ${name} invalide\n${usage}`, missingArgument: (name, usage) => `⚠️ ${name} manquant\n${usage}`, tooManyArguments: (usage) => `⚠️ trop d'arguments\n${usage}`, usage: (usage) => `⚠️ usage\n${usage}`, + helperChannel: (names) => `Commandes : ${names.join(', ')} · /help en DM`, noPlugins: 'Aucun plugin', pluginLine, pluginLoaded: (name, count) => `✅ ${name} chargé (${count})`, diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 1909e1d..5079af6 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,6 +1,7 @@ import { RadioErrorCode } from '@meshcorejs/protocol'; import { MeshcoreError } from '@meshcorejs/transports'; import type { RadioSetting } from './radio/radio-config.js'; +import type { Channel } from './structures/channel.js'; export { ConnectionError, MeshcoreError, MissingDependencyError } from '@meshcorejs/transports'; @@ -112,6 +113,97 @@ export class MessageTooLongError extends MeshcoreError { } } +/** + * Thrown by `channel.send()` and `message.reply()` on the Public channel: a bot never speaks first on Public. + * Only `ctx.reply()` of a command that opted in with `setScope('public')` may answer there. + */ +export class PublicChannelError extends MeshcoreError { + constructor() { + super( + 'PUBLIC_CHANNEL', + "sending on the Public channel is refused: only a command with setScope('public') can reply there", + ); + } +} + +/** + * Thrown by `client.radio.request()` for the three Companion commands that transmit on the mesh + * (`SendTxtMsg`, `SendChannelTxtMsg`, `SendSelfAdvert`): they only go through `SendQueue` and `sendSelfAdvert()`, + * which carry the anti-spam limits. + */ +export class GuardedCommandError extends MeshcoreError { + readonly commandCode: number; + + /** @param commandCode The refused Companion command code */ + constructor(commandCode: number) { + super( + 'GUARDED_COMMAND', + `command 0x${commandCode.toString(16)} transmits on the mesh: use channel.send() / contact.send() / radio.sendSelfAdvert()`, + ); + this.commandCode = commandCode; + } +} + +/** What a `RateLimitError` protects: a channel's outbound budget, or the mesh-wide flood advert. */ +export type RateLimitedResource = 'channel' | 'advert'; + +/** + * Thrown when a fixed outbound limit is reached: `CHANNEL_SEND_LIMIT` parts per `CHANNEL_SEND_WINDOW_MS` on + * one channel, or one flood advert per `ADVERT_FLOOD_INTERVAL_MS`. Nothing is queued: retry after `retryAfterMs`. + */ +export class RateLimitError extends MeshcoreError { + readonly resource: RateLimitedResource; + readonly channel: Channel | null; + readonly limit: number; + readonly windowMs: number; + readonly retryAfterMs: number; + + /** + * @param resource channel or advert + * @param channel The channel, when resource is channel + * @param limit Sends allowed per window + * @param windowMs Sliding window + * @param retryAfterMs Delay before one send is allowed again + */ + constructor( + resource: RateLimitedResource, + channel: Channel | null, + limit: number, + windowMs: number, + retryAfterMs: number, + ) { + const minutes = Math.round(windowMs / 60_000); + const retry = Number.isFinite(retryAfterMs) ? `retry in ${Math.ceil(retryAfterMs / 1000)} s` : 'it never fits'; + super( + 'RATE_LIMIT', + resource === 'channel' + ? `channel "${channel?.name ?? '?'}" already got ${limit} messages in the last ${minutes} min, ${retry}` + : `a flood advert was already sent in the last ${minutes} min, ${retry}`, + ); + this.resource = resource; + this.channel = channel; + this.limit = limit; + this.windowMs = windowMs; + this.retryAfterMs = retryAfterMs; + } +} + +/** Thrown by `message.reply()` when the message came from further than the client's `maxHops`. */ +export class TooFarError extends MeshcoreError { + readonly hopCount: number; + readonly maxHops: number; + + /** + * @param hopCount Hops the message travelled + * @param maxHops The client's limit + */ + constructor(hopCount: number, maxHops: number) { + super('TOO_FAR', `message came through ${hopCount} hops, the client answers up to ${maxHops}`); + this.hopCount = hopCount; + this.maxHops = maxHops; + } +} + /** Why a DM delivery failed: no ACK after retries, expired in the queue, a radio error, or the client was destroyed. */ export type DeliveryFailureReason = 'noAck' | 'expired' | 'radio' | 'destroyed'; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7d82e3f..4c76823 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -44,6 +44,7 @@ export { ConnectionError, DeliveryFailedError, type DeliveryFailureReason, + GuardedCommandError, JobTimeoutError, LimitReachedError, LoadError, @@ -51,8 +52,12 @@ export { MeshcoreError, MessageTooLongError, MissingDependencyError, + PublicChannelError, RadioConfigError, RadioError, + RateLimitError, + type RateLimitedResource, + TooFarError, UnsupportedFirmwareError, } from './errors.js'; export { EventBuilder, type EventDefinition, type EventName } from './events/event-builder.js'; @@ -70,6 +75,8 @@ export { createConsoleLogger, type Logger, type LogLevel, silentLogger } from '. export { ChannelManager, type CreateChannelOptions, hashtagChannelSecret } from './managers/channel-manager.js'; export { type Advert, ContactManager } from './managers/contact-manager.js'; export { + CHANNEL_SEND_LIMIT, + CHANNEL_SEND_WINDOW_MS, DM_MAX_RESENDS, type MessageContent, SEND_EXPIRY_MS, @@ -104,9 +111,10 @@ export { export { Plugin, type PluginState } from './plugins/plugin.js'; export { type PluginBricksFactory, PluginBuilder, type PluginDefinition } from './plugins/plugin-builder.js'; export { PluginManager } from './plugins/plugin-manager.js'; -export { Radio, type RadioEvents, type RadioOptions } from './radio/radio.js'; +export { ADVERT_FLOOD_INTERVAL_MS, expectType, Radio, type RadioEvents, type RadioOptions } from './radio/radio.js'; export { RadioConfig, type RadioConfigOptions, type RadioSetting } from './radio/radio-config.js'; export type { Location, RadioParams } from './radio/radio-settings.js'; +export type { Collector } from './radio/request-queue.js'; export { Channel } from './structures/channel.js'; export { Contact, type ContactKind } from './structures/contact.js'; export { type Author, Message, type MessageData, type UnverifiedAuthor } from './structures/message.js'; diff --git a/packages/client/src/messages/send-queue.ts b/packages/client/src/messages/send-queue.ts index cd907ef..fd5927f 100644 --- a/packages/client/src/messages/send-queue.ts +++ b/packages/client/src/messages/send-queue.ts @@ -1,7 +1,14 @@ import { utf8ByteLength } from '@meshcorejs/protocol'; import { MessageBuilder } from '../builders/message-builder.js'; import type { Client } from '../client/client.js'; -import { CommandTimeoutError, ConnectionError, DeliveryFailedError, MessageTooLongError } from '../errors.js'; +import { + CommandTimeoutError, + ConnectionError, + DeliveryFailedError, + MessageTooLongError, + PublicChannelError, + RateLimitError, +} from '../errors.js'; import { Channel } from '../structures/channel.js'; import type { Contact } from '../structures/contact.js'; import { SentMessage } from './sent-message.js'; @@ -16,6 +23,10 @@ export const SEND_INTERVAL_MS = 2000; export const DM_MAX_RESENDS = 3; /** How long a queued message may wait for the radio before it fails with `DeliveryFailedError`, not configurable. */ export const SEND_EXPIRY_MS = 5 * 60_000; +/** Parts a channel accepts per `CHANNEL_SEND_WINDOW_MS`; beyond it `send()` rejects with `RateLimitError`. Not configurable. */ +export const CHANNEL_SEND_LIMIT = 10; +/** Sliding window of the per-channel outbound limit, not configurable. */ +export const CHANNEL_SEND_WINDOW_MS = 5 * 60_000; interface QueuedPart { message: SentMessage; @@ -33,9 +44,14 @@ interface AckWait { acks: number[]; } -/** Options for `SendQueue.send()`: an extra `prefix` (a mention) counted in the target's byte budget. */ +/** + * Options for `SendQueue.send()`: an extra `prefix` (a mention) counted in the target's byte budget. + * `allowPublic` is set by the command pipeline only. + */ export interface SendOptions { prefix?: string; + /** @internal */ + allowPublic?: boolean; } /** @@ -52,6 +68,7 @@ export class SendQueue { #busy = false; #closed = false; readonly #idleWaiters: Array<() => void> = []; + readonly #channelSends = new Map(); /** @param client Owning client */ constructor(client: Client) { @@ -81,6 +98,7 @@ export class SendQueue { */ async send(target: Contact | Channel, content: MessageContent, options: SendOptions = {}): Promise { if (this.#closed) throw new ConnectionError('client destroyed'); + if (target instanceof Channel && target.isPublic && !options.allowPublic) throw new PublicChannelError(); const prefix = target instanceof Channel ? (options.prefix ?? '') : ''; const budget = this.budgetFor(target, prefix); let texts: string[]; @@ -92,6 +110,7 @@ export class SendQueue { if (bytes > budget) throw new MessageTooLongError(bytes, budget); texts = [content]; } + if (target instanceof Channel) this.#reserveChannelBudget(target, texts.length); const message = new SentMessage( target, texts.map((text) => `${prefix}${text}`), @@ -141,6 +160,29 @@ export class SendQueue { } } + /** Sliding window per channel, counted in parts at acceptance; a message that does not fit whole is refused whole. */ + #reserveChannelBudget(channel: Channel, parts: number): void { + const now = Date.now(); + const stamps = (this.#channelSends.get(channel.index) ?? []).filter((at) => now - at < CHANNEL_SEND_WINDOW_MS); + this.#channelSends.set(channel.index, stamps); + if (parts > CHANNEL_SEND_LIMIT) { + throw new RateLimitError( + 'channel', + channel, + CHANNEL_SEND_LIMIT, + CHANNEL_SEND_WINDOW_MS, + Number.POSITIVE_INFINITY, + ); + } + const excess = stamps.length + parts - CHANNEL_SEND_LIMIT; + if (excess > 0) { + // stamps are chronological: the excess-th oldest one must expire before this message fits + const frees = (stamps[excess - 1] as number) + CHANNEL_SEND_WINDOW_MS - now; + throw new RateLimitError('channel', channel, CHANNEL_SEND_LIMIT, CHANNEL_SEND_WINDOW_MS, Math.max(0, frees)); + } + for (let i = 0; i < parts; i++) stamps.push(now); + } + #pump(): void { if (this.#busy || this.#timer || this.#closed) return; if (this.#parts.length === 0) { diff --git a/packages/client/src/radio/radio.ts b/packages/client/src/radio/radio.ts index 3c8547f..9d80ee4 100644 --- a/packages/client/src/radio/radio.ts +++ b/packages/client/src/radio/radio.ts @@ -2,6 +2,7 @@ import { type BattAndStorageFrame, type ChannelMessageFrame, type ChannelRecord, + CommandCode, type ContactMessageFrame, type ContactRecord, contactUri, @@ -36,7 +37,7 @@ import { type SetChannelParams, } from '@meshcorejs/protocol'; import { type Transport, TypedEmitter } from '@meshcorejs/transports'; -import { RadioError } from '../errors.js'; +import { GuardedCommandError, RadioError, RateLimitError } from '../errors.js'; import { assertLocation, assertRadioName, @@ -59,7 +60,13 @@ export interface RadioOptions { timeoutMs?: number; } -function expectType( +/** + * A `Collector` that resolves on the first response frame of `type`, mapped by `map`, and ignores the rest. + * For `client.radio.request()` on commands whose answer is a structured frame. + * @param type Response frame type (`DecodedFrame['type']`) + * @param map Turns that frame into the request's result + */ +export function expectType( type: T, map: (frame: Extract) => R, ): Collector { @@ -68,14 +75,30 @@ function expectType( const ok: Collector = expectType('ok', () => undefined); +/** The Companion commands that put a packet on the air; `request()` refuses them. */ +const GUARDED_COMMANDS: ReadonlySet = new Set([ + CommandCode.SendTxtMsg, + CommandCode.SendChannelTxtMsg, + CommandCode.SendSelfAdvert, +]); + +function commandLabel(code: number): string { + const name = Object.entries(CommandCode).find(([, value]) => value === code)?.[0]; + return name ?? `CMD_0x${code.toString(16)}`; +} + const isNotFound = (error: unknown) => error instanceof RadioError && error.radioCode === RadioErrorCode.NotFound; +/** Minimum delay between two flood adverts (`sendSelfAdvert(true)`), not configurable. Zero-hop adverts are free. */ +export const ADVERT_FLOOD_INTERVAL_MS = 30 * 60_000; + /** * `client.radio`: typed wrappers around the Companion commands (`appStart`, `deviceQuery`, `getContacts`, * `sendText`, …), one request in flight at a time. */ export class Radio extends TypedEmitter { readonly #queue: RequestQueue; + #lastFloodAdvertAt = Number.NEGATIVE_INFINITY; /** * @param transport Connected or not, the radio only writes frames @@ -92,6 +115,28 @@ export class Radio extends TypedEmitter { this.#queue.rejectAll(error); } + /** + * Send a Companion command that has no typed wrapper, through the same one-request-in-flight queue as the + * wrappers. Never write raw frames to `client.transport`. The three commands that transmit on the mesh are + * refused with `GuardedCommandError`. + * @param code Companion command code (`CommandCode.*`, or a byte the protocol does not know yet) + * @param payload Command body without the code byte. Default empty + * @param collect Turns response frames into the result. Default: expects `Ok` + */ + async request( + code: number, + payload: Uint8Array = new Uint8Array(0), + collect: Collector = ok as Collector, + ): Promise { + if (!Number.isInteger(code) || code < 0 || code > 0xff) + throw new RangeError(`command code must be a byte, got ${code}`); + if (GUARDED_COMMANDS.has(code)) throw new GuardedCommandError(code); + const frame = new Uint8Array(payload.length + 1); + frame[0] = code; + frame.set(payload, 1); + return this.#queue.request(commandLabel(code), frame, collect); + } + #onFrame(payload: Uint8Array): void { const frame = decodeFrame(payload); this.emit('raw', frame); @@ -215,12 +260,38 @@ export class Radio extends TypedEmitter { return { packet, uri: contactUri(packet) }; } - /** @param flood Flood the mesh instead of a zero-hop advert */ - sendSelfAdvert(flood: boolean): Promise { - return this.#queue.request('SEND_SELF_ADVERT', encodeSendSelfAdvert({ flood }), ok); + /** + * @param flood Flood the mesh instead of a zero-hop advert; at most one flood per `ADVERT_FLOOD_INTERVAL_MS` + * (`RateLimitError` otherwise), the window is reserved before the request so two concurrent calls cannot both + * pass, and released again only when the failure proves nothing was transmitted (a `RadioError`, i.e. the + * firmware answered `Err`). Any other failure, notably a `CommandTimeoutError`, keeps the window consumed: + * a lost reply does not mean the flood never went out, and the reconnect loop retries `login()` on its own. + */ + async sendSelfAdvert(flood: boolean): Promise { + if (!flood) { + await this.#queue.request('SEND_SELF_ADVERT', encodeSendSelfAdvert({ flood }), ok); + return; + } + const elapsed = Date.now() - this.#lastFloodAdvertAt; + if (elapsed < ADVERT_FLOOD_INTERVAL_MS) { + throw new RateLimitError('advert', null, 1, ADVERT_FLOOD_INTERVAL_MS, ADVERT_FLOOD_INTERVAL_MS - elapsed); + } + const previous = this.#lastFloodAdvertAt; + this.#lastFloodAdvertAt = Date.now(); // reserve before awaiting: a concurrent call is refused + try { + await this.#queue.request('SEND_SELF_ADVERT', encodeSendSelfAdvert({ flood }), ok); + } catch (error) { + if (error instanceof RadioError) this.#lastFloodAdvertAt = previous; + throw error; + } } - /** @param params Recipient, text, timestamp and attempt */ + /** + * Unguarded wrapper around `SEND_TXT_MSG`, used only by `SendQueue`. Bypasses every anti-spam guard: never + * call it directly, send through `contact.send()` / `channel.send()` instead. + * @param params Recipient, text, timestamp and attempt + * @internal + */ sendText(params: SendTxtMsgParams): Promise { return this.#queue.request( 'SEND_TXT_MSG', @@ -229,7 +300,12 @@ export class Radio extends TypedEmitter { ); } - /** @param params Channel slot, text and timestamp */ + /** + * Unguarded wrapper around `SEND_CHANNEL_TXT_MSG`, used only by `SendQueue`. Bypasses every anti-spam guard + * (Public, the per-channel rate limit): never call it directly, send through `channel.send()` instead. + * @param params Channel slot, text and timestamp + * @internal + */ sendChannelText(params: SendChannelTxtMsgParams): Promise { return this.#queue.request('SEND_CHANNEL_TXT_MSG', encodeSendChannelTxtMsg(params), ok); } diff --git a/packages/client/src/radio/request-queue.ts b/packages/client/src/radio/request-queue.ts index 5f9549f..788d439 100644 --- a/packages/client/src/radio/request-queue.ts +++ b/packages/client/src/radio/request-queue.ts @@ -1,6 +1,10 @@ import type { DecodedFrame } from '@meshcorejs/protocol'; import { CommandTimeoutError, RadioError } from '../errors.js'; +/** + * Decides what a request does with each response frame: `{ value }` resolves it, `'continue'` keeps waiting + * (multi-frame answers), `'ignore'` leaves the frame to others. + */ export type Collector = (frame: DecodedFrame) => { value: T } | 'continue' | 'ignore'; interface PendingRequest { diff --git a/packages/client/src/structures/channel.ts b/packages/client/src/structures/channel.ts index f2fb6bc..19e6420 100644 --- a/packages/client/src/structures/channel.ts +++ b/packages/client/src/structures/channel.ts @@ -1,4 +1,4 @@ -import { type ChannelRecord, toHex } from '@meshcorejs/protocol'; +import { type ChannelRecord, PUBLIC_CHANNEL_SECRET, toHex } from '@meshcorejs/protocol'; import type { Client } from '../client/client.js'; import type { MessageContent } from '../messages/send-queue.js'; import type { SentMessage } from '../messages/sent-message.js'; @@ -33,6 +33,14 @@ export class Channel { return this.#record.name.startsWith('#'); } + /** True for the firmware's built-in Public channel, identified by its secret (a renamed Public is still Public). */ + get isPublic(): boolean { + const secret = this.#record.secret; + return ( + secret.length === PUBLIC_CHANNEL_SECRET.length && secret.every((byte, i) => byte === PUBLIC_CHANNEL_SECRET[i]) + ); + } + /** @param content Text or MessageBuilder */ send(content: MessageContent): Promise { return this.client.sendQueue.send(this, content); diff --git a/packages/client/src/structures/message.ts b/packages/client/src/structures/message.ts index cce9e57..1acc4ad 100644 --- a/packages/client/src/structures/message.ts +++ b/packages/client/src/structures/message.ts @@ -1,4 +1,5 @@ import type { Client } from '../client/client.js'; +import { TooFarError } from '../errors.js'; import type { MessageContent } from '../messages/send-queue.js'; import type { SentMessage } from '../messages/sent-message.js'; import { mentionPrefix } from '../messages/text.js'; @@ -55,15 +56,28 @@ export class Message { return this.channel === null; } + /** True when the message travelled more hops than the client's `maxHops`; false without a limit or an unknown route. */ + get tooFar(): boolean { + const max = this.client.maxHops; + return max !== null && this.hopCount !== null && this.hopCount > max; + } + /** * @param content Text or MessageBuilder * @param options mention prefixes the author on a channel. Default true */ reply(content: MessageContent, options: { mention?: boolean } = {}): Promise { + return this._reply(content, options, false); + } + + /** @internal The command pipeline's door: `allowPublic` lets a `'public'` command answer on Public. */ + async _reply(content: MessageContent, options: { mention?: boolean }, allowPublic: boolean): Promise { + if (this.tooFar) throw new TooFarError(this.hopCount as number, this.client.maxHops as number); if (this.channel === null) return (this.author as Contact).send(content); const mention = (options.mention ?? true) && this.author.name !== ''; return this.client.sendQueue.send(this.channel, content, { prefix: mention ? mentionPrefix(this.author.name) : '', + allowPublic, }); } } diff --git a/packages/client/test/advert-limit.test.ts b/packages/client/test/advert-limit.test.ts new file mode 100644 index 0000000..d71596a --- /dev/null +++ b/packages/client/test/advert-limit.test.ts @@ -0,0 +1,106 @@ +import { CommandCode } from '@meshcorejs/protocol'; +import { FakeRadio, MockTransport } from '@meshcorejs/transports/mock'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '../src/client/client.js'; +import { CommandTimeoutError, RadioError, RateLimitError } from '../src/errors.js'; +import type { Logger } from '../src/logger.js'; +import { ADVERT_FLOOD_INTERVAL_MS } from '../src/radio/radio.js'; +import { RadioConfig } from '../src/radio/radio-config.js'; +import { setupClient } from './helpers.js'; + +beforeEach(() => { + vi.useFakeTimers({ now: new Date('2026-09-22T12:00:00Z') }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const adverts = (codes: number[]) => codes.filter((c) => c === CommandCode.SendSelfAdvert).length; + +describe('flood advert limit', () => { + it('allows one flood advert per 30 minutes, zero-hop adverts always', async () => { + const { client, radio } = await setupClient(); + await client.radio.sendSelfAdvert(true); + const refused = client.radio.sendSelfAdvert(true); + await expect(refused).rejects.toBeInstanceOf(RateLimitError); + await expect(refused).rejects.toMatchObject({ + resource: 'advert', + channel: null, + limit: 1, + windowMs: ADVERT_FLOOD_INTERVAL_MS, + retryAfterMs: ADVERT_FLOOD_INTERVAL_MS, + }); + await client.radio.sendSelfAdvert(false); + await client.radio.sendSelfAdvert(false); + expect(adverts(radio.commands)).toBe(3); + await vi.advanceTimersByTimeAsync(ADVERT_FLOOD_INTERVAL_MS); + await expect(client.radio.sendSelfAdvert(true)).resolves.toBeUndefined(); + expect(adverts(radio.commands)).toBe(4); + }); + + it('counts the login advert and logs instead of failing when it is refused', async () => { + // Same shape as client-radio-config.test.ts: a real Client with a spying logger. + const transport = new MockTransport(); + const radio = new FakeRadio({ self: { name: 'MeshCore-1234' } }).attach(transport); + const warnings: string[] = []; + const logger: Logger = { debug() {}, info() {}, warn: (m) => warnings.push(String(m)), error() {} }; + const client = new Client({ transport, logger, radio: new RadioConfig({ name: 'TrainBot' }) }); + await client.login(); + expect(adverts(radio.commands)).toBe(1); + + // The radio "forgets" the name: the next login applies the config again and wants a second flood advert. + radio.self.name = 'MeshCore-1234'; + transport.simulateDisconnect(); + await vi.advanceTimersByTimeAsync(1000); + expect(client.status).toBe('ready'); + expect(adverts(radio.commands)).toBe(1); + expect(warnings).toEqual([ + 'disconnected from the radio: mock connection lost', + expect.stringMatching(/^radio: flood advert skipped, /), + ]); + + await vi.advanceTimersByTimeAsync(ADVERT_FLOOD_INTERVAL_MS); + radio.self.name = 'MeshCore-1234'; + transport.simulateDisconnect(); + await vi.advanceTimersByTimeAsync(1000); + expect(adverts(radio.commands)).toBe(2); + }); + + it('reserves the window synchronously so two concurrent flood adverts cannot both pass', async () => { + const { client, radio } = await setupClient(); + const first = client.radio.sendSelfAdvert(true); + const second = client.radio.sendSelfAdvert(true); + await expect(second).rejects.toBeInstanceOf(RateLimitError); + await expect(first).resolves.toBeUndefined(); + expect(adverts(radio.commands)).toBe(1); + }); + + it('rolls back the window on a firmware refusal, so a later flood advert is still allowed', async () => { + const { client, radio } = await setupClient(); + radio.refuseSelfAdvert = true; + await expect(client.radio.sendSelfAdvert(true)).rejects.toBeInstanceOf(RadioError); + expect(adverts(radio.commands)).toBe(1); + + radio.refuseSelfAdvert = false; + await expect(client.radio.sendSelfAdvert(true)).resolves.toBeUndefined(); + expect(adverts(radio.commands)).toBe(2); + }); + + it('keeps the window consumed on a timeout, since a lost reply does not prove nothing was sent', async () => { + const { client, radio } = await setupClient(); + radio.unresponsive = true; + const failed = expect(client.radio.sendSelfAdvert(true)).rejects.toBeInstanceOf(CommandTimeoutError); + await vi.advanceTimersByTimeAsync(5000); + await failed; + expect(adverts(radio.commands)).toBe(0); + + radio.unresponsive = false; + await expect(client.radio.sendSelfAdvert(true)).rejects.toBeInstanceOf(RateLimitError); + expect(adverts(radio.commands)).toBe(0); + + await vi.advanceTimersByTimeAsync(ADVERT_FLOOD_INTERVAL_MS); + await expect(client.radio.sendSelfAdvert(true)).resolves.toBeUndefined(); + expect(adverts(radio.commands)).toBe(1); + }); +}); diff --git a/packages/client/test/channel-silence.test.ts b/packages/client/test/channel-silence.test.ts new file mode 100644 index 0000000..4a50216 --- /dev/null +++ b/packages/client/test/channel-silence.test.ts @@ -0,0 +1,106 @@ +import { fakeContactRecord } from '@meshcorejs/transports/mock'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CommandBuilder } from '../src/commands/command-builder.js'; +import type { DenyReason } from '../src/commands/context.js'; +import { frenchReplies } from '../src/commands/replies.js'; +import { Permissions } from '../src/permissions/permission-builder.js'; +import { flush, setupClient } from './helpers.js'; + +beforeEach(() => { + vi.useFakeTimers({ now: new Date('2026-09-22T12:00:00Z') }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const julie = fakeContactRecord({ name: 'Julie' }); +const lyon = { index: 1, name: '#lyon', secret: new Uint8Array(16) }; + +const ping = new CommandBuilder() + .setName('ping') + .setCooldown(60) + .setHandler((ctx) => ctx.reply('pong')); +const train = new CommandBuilder() + .setName('train') + .addStringArg((a) => a.setName('numero').setRequired()) + .setHandler((ctx) => ctx.reply(`🚆 ${ctx.args.numero}`)); +const admin = new CommandBuilder() + .setName('admin') + .setRequiredPermissions(Permissions.Administrator) + .setHandler((ctx) => ctx.reply('ok')); + +async function setup(options: Parameters[0] = {}) { + const context = await setupClient({ + self: { name: 'TrainBot' }, + contacts: [julie], + channels: [lyon], + login: false, + ...options, + }); + const denied: DenyReason[] = []; + context.client.on('commandDenied', (_ctx, reason) => denied.push(reason)); + context.client.register([ping, train, admin]); + await context.client.login(); + let seen = 0; + const next = async () => { + await flush(); + await vi.advanceTimersByTimeAsync(10_000); + const texts = context.radio.sent.slice(seen).map((m) => m.text); + seen = context.radio.sent.length; + return texts; + }; + const channel = (text: string) => context.radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text }); + const dm = (text: string) => context.radio.receiveContactMessage({ from: julie.publicKey, text }); + return { ...context, denied, next, channel, dm }; +} + +describe('on a channel the bot only speaks to answer an executed command', () => { + it('stays silent on every refusal, but still emits commandDenied', async () => { + const { denied, next, channel } = await setup(); + channel('@TrainBot nope'); + channel('@TrainBot admin'); + channel('@TrainBot train'); + channel('@TrainBot ping'); + channel('@TrainBot ping'); + expect(await next()).toEqual(['@[Léa] pong']); + expect(denied.map((d) => d.type)).toEqual(['unknownCommand', 'channelUntrusted', 'invalidArguments', 'cooldown']); + }); + + it('keeps answering refusals in DM', async () => { + const { next, dm } = await setup(); + dm('/nope'); + dm('/train'); + dm('/ping'); + dm('/ping'); + expect(await next()).toEqual([ + '❓ Unknown command, /help', + '⚠️ numero is missing\n/train ', + 'pong', + '⏳ Try again in 60s', + ]); + }); + + it('answers @Bot on a channel with one line, in DM with the full list', async () => { + const { next, channel, dm } = await setup(); + channel('@TrainBot'); + expect(await next()).toEqual(['@[Léa] Commands: ping, train · DM me /help']); + dm('/help'); + expect(await next()).toEqual(['/ping\n/train ']); + }); + + it('translates the channel helper line', async () => { + const { next, channel } = await setup({ replies: frenchReplies }); + channel('@TrainBot'); + expect(await next()).toEqual(['@[Léa] Commandes : ping, train · /help en DM']); + }); + + it('keeps the channel helper line within the byte budget', async () => { + const { client, next, channel } = await setup(); + for (let i = 0; i < 30; i++) client.register(new CommandBuilder().setName(`commande${i}`).setHandler(() => {})); + channel('@TrainBot'); + const [line] = await next(); + expect(line).toMatch(/^@\[Léa\] Commands: ping, train, commande0, .*… · DM me \/help$/); + expect(new TextEncoder().encode(`TrainBot: ${line}`).length).toBeLessThanOrEqual(160); + }); +}); diff --git a/packages/client/test/channels.test.ts b/packages/client/test/channels.test.ts index d3c5e90..eb3bbaa 100644 --- a/packages/client/test/channels.test.ts +++ b/packages/client/test/channels.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { toHex } from '@meshcorejs/protocol'; +import { PUBLIC_CHANNEL_SECRET, toHex } from '@meshcorejs/protocol'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LimitReachedError, MeshcoreError } from '../src/errors.js'; import { hashtagChannelSecret } from '../src/managers/channel-manager.js'; @@ -68,3 +68,18 @@ describe('ChannelManager', () => { ]); }); }); + +describe('Channel.isPublic', () => { + it('recognises Public by its secret, whatever its name', async () => { + const { client } = await setupClient({ + channels: [ + { index: 0, name: 'Public', secret: PUBLIC_CHANNEL_SECRET }, + { index: 1, name: 'Public', secret: new Uint8Array(16) }, + { index: 2, name: 'Général', secret: Uint8Array.from(PUBLIC_CHANNEL_SECRET) }, + ], + }); + expect(client.channels.get(0)!.isPublic).toBe(true); + expect(client.channels.get(1)!.isPublic).toBe(false); + expect(client.channels.get(2)!.isPublic).toBe(true); + }); +}); diff --git a/packages/client/test/command-permissions.test.ts b/packages/client/test/command-permissions.test.ts index 89868a3..290d262 100644 --- a/packages/client/test/command-permissions.test.ts +++ b/packages/client/test/command-permissions.test.ts @@ -117,7 +117,7 @@ describe('commands with required permissions', () => { it('reproduces the French transcript of the v1 spec with frenchReplies', async () => { const { dm, channel } = await trainBot({ replies: frenchReplies }); expect(await dm(marc, '/promote Léa moderateur')).toEqual(['⛔ Permission refusée']); - expect(await channel('Théo', '@TrainBot promote Léa admin')).toEqual(['@[Théo] ↪️ Envoie-moi cette commande en DM']); + expect(await channel('Théo', '@TrainBot promote Léa admin')).toEqual([]); }); it('follows the promote / demote transcript of the spec', async () => { @@ -140,7 +140,7 @@ describe('commands with required permissions', () => { it('refuses protected commands on channels, even for owners', async () => { const { channel, denied } = await trainBot(); - expect(await channel('Théo', '@TrainBot promote Léa admin')).toEqual(['@[Théo] ↪️ Send me this command in a DM']); + expect(await channel('Théo', '@TrainBot promote Léa admin')).toEqual([]); expect(denied).toEqual([{ type: 'channelUntrusted' }]); }); @@ -150,7 +150,7 @@ describe('commands with required permissions', () => { expect(await dm(theo, '/help')).toEqual([ '/train \n/alerte \n/promote \n/demote \n/plugins [action] [name]\n/jobs [action] [name]', ]); - expect(await channel('Théo', '@TrainBot')).toEqual(['@[Théo] @TrainBot train ']); + expect(await channel('Théo', '@TrainBot')).toEqual(['@[Théo] Commands: train · DM me /help']); }); it('refuses unknown permission references at login', async () => { diff --git a/packages/client/test/commands.test.ts b/packages/client/test/commands.test.ts index 9daeefc..92c606d 100644 --- a/packages/client/test/commands.test.ts +++ b/packages/client/test/commands.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MessageBuilder } from '../src/builders/message-builder.js'; import { CommandBuilder } from '../src/commands/command-builder.js'; import type { DenyReason } from '../src/commands/context.js'; +import { RateLimitError } from '../src/errors.js'; import { flush, setupClient } from './helpers.js'; beforeEach(() => { @@ -105,7 +106,7 @@ describe('command pipeline', () => { radio.receiveContactMessage({ from: julie.publicKey, text: '/trian 6607' }); expect(await next()).toEqual(['❓ Unknown command, /help']); radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: '@TrainBot trian' }); - expect(await next()).toEqual(['@[Léa] ❓ Unknown command']); + expect(await next()).toEqual([]); expect(denied).toEqual([ { type: 'unknownCommand', name: 'trian' }, { type: 'unknownCommand', name: 'trian' }, @@ -130,7 +131,7 @@ describe('command pipeline', () => { radio.receiveContactMessage({ from: julie.publicKey, text: '/train abc' }); expect(await next()).toEqual(['⚠️ numero is invalid\n/train ']); radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: '@TrainBot train' }); - expect(await next()).toEqual(['@[Léa] ⚠️ numero is missing\n@TrainBot train ']); + expect(await next()).toEqual([]); expect(denied.map((d) => d.type)).toEqual(['invalidArguments', 'invalidArguments']); }); @@ -213,6 +214,42 @@ describe('command pipeline', () => { expect(onError).toHaveBeenCalledWith(new Error('async failure'), { type: 'command', name: 'boom' }); }); + it('does not try to answer internalError when the channel is rate limited', async () => { + const { client, radio } = await setup(); + const boom = new CommandBuilder().setName('boom').setHandler(async (ctx) => { + for (let i = 0; i < 11; i++) await ctx.reply(`part ${i}`); + }); + client.register(boom); + await client.login(); + const errors = vi.fn(); + client.on('commandError', errors); + const next = sentTexts(radio); + radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: '@TrainBot boom' }); + await vi.advanceTimersByTimeAsync(30_000); + const texts = await next(); + expect(texts).toHaveLength(10); + expect(texts).not.toContain('@[Léa] ❌ Internal error'); + expect(errors).toHaveBeenCalledTimes(1); + expect(errors.mock.calls[0]?.[0]).toBeInstanceOf(RateLimitError); + }); + + it('still answers internalError when a handler leaks an advert rate limit, unrelated to the channel budget', async () => { + const { client, radio } = await setup(); + const boom = new CommandBuilder().setName('boom').setHandler(async () => { + throw new RateLimitError('advert', null, 1, 1_800_000, 1000); + }); + client.register(boom); + await client.login(); + const errors = vi.fn(); + client.on('commandError', errors); + const next = sentTexts(radio); + radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: '@TrainBot boom' }); + const texts = await next(); + expect(texts).toEqual(['@[Léa] ❌ Internal error']); + expect(errors).toHaveBeenCalledTimes(1); + expect(errors.mock.calls[0]?.[0]).toBeInstanceOf(RateLimitError); + }); + it('refuses to log in with colliding names and aliases', async () => { const { client } = await setup(); client.register([train, new CommandBuilder().setName('t').setHandler(() => {})]); @@ -238,7 +275,7 @@ describe('built-in helper', () => { await client.login(); const next = sentTexts(radio); radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: '@TrainBot' }); - expect(await next()).toEqual(['@[Léa] @TrainBot train ']); + expect(await next()).toEqual(['@[Léa] Commands: train · DM me /help']); }); it('says when nothing is available and splits long lists', async () => { diff --git a/packages/client/test/helpers.ts b/packages/client/test/helpers.ts index 7da9c96..d3ab923 100644 --- a/packages/client/test/helpers.ts +++ b/packages/client/test/helpers.ts @@ -8,13 +8,19 @@ export interface SetupOptions extends FakeRadioOptions { /** Call client.login() before returning. Default true. */ login?: boolean; replies?: Partial; + maxHops?: number; } export async function setupClient(options: SetupOptions = {}) { - const { login = true, replies, ...radioOptions } = options; + const { login = true, replies, maxHops, ...radioOptions } = options; const transport = new MockTransport(); const radio = new FakeRadio(radioOptions).attach(transport); - const client = new Client({ transport, logger: silentLogger, ...(replies ? { replies } : {}) }); + const client = new Client({ + transport, + logger: silentLogger, + ...(replies ? { replies } : {}), + ...(maxHops !== undefined ? { maxHops } : {}), + }); if (login) await client.login(); return { client, radio, transport }; } diff --git a/packages/client/test/index.test.ts b/packages/client/test/index.test.ts index 381b50d..09f0e56 100644 --- a/packages/client/test/index.test.ts +++ b/packages/client/test/index.test.ts @@ -5,7 +5,10 @@ describe('public API', () => { it('exports the core runtime values', () => { expect(Object.keys(meshcore).sort()).toEqual( [ + 'ADVERT_FLOOD_INTERVAL_MS', 'BleTransport', + 'CHANNEL_SEND_LIMIT', + 'CHANNEL_SEND_WINDOW_MS', 'Channel', 'ChannelManager', 'Client', @@ -15,6 +18,7 @@ describe('public API', () => { 'ConnectionError', 'Contact', 'ContactManager', + 'GuardedCommandError', 'HEALTH_CHECK_INTERVAL_MS', 'HEALTH_CHECK_MAX_FAILURES', 'LimitReachedError', @@ -32,6 +36,7 @@ describe('public API', () => { 'TypedEmitter', 'UnsupportedFirmwareError', 'createConsoleLogger', + 'expectType', 'hashtagChannelSecret', 'isBrick', 'silentLogger', @@ -45,7 +50,10 @@ describe('public API', () => { 'Plugin', 'PluginBuilder', 'PluginManager', + 'PublicChannelError', 'Permissions', + 'RateLimitError', + 'TooFarError', 'Role', 'RoleBuilder', 'RoleManager', diff --git a/packages/client/test/max-hops.test.ts b/packages/client/test/max-hops.test.ts new file mode 100644 index 0000000..c9a97d0 --- /dev/null +++ b/packages/client/test/max-hops.test.ts @@ -0,0 +1,101 @@ +import { fakeContactRecord, MockTransport } from '@meshcorejs/transports/mock'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '../src/client/client.js'; +import { CommandBuilder } from '../src/commands/command-builder.js'; +import type { DenyReason } from '../src/commands/context.js'; +import { TooFarError } from '../src/errors.js'; +import { EventBuilder } from '../src/events/event-builder.js'; +import { silentLogger } from '../src/logger.js'; +import { flush, setupClient } from './helpers.js'; + +beforeEach(() => { + vi.useFakeTimers({ now: new Date('2026-09-22T12:00:00Z') }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const julie = fakeContactRecord({ name: 'Julie' }); +const lyon = { index: 1, name: '#lyon', secret: new Uint8Array(16) }; +const ping = new CommandBuilder().setName('ping').setHandler((ctx) => ctx.reply('pong')); + +async function setup(maxHops?: number) { + const context = await setupClient({ + self: { name: 'TrainBot' }, + contacts: [julie], + channels: [lyon], + login: false, + ...(maxHops !== undefined ? { maxHops } : {}), + }); + const denied: DenyReason[] = []; + context.client.on('commandDenied', (_ctx, reason) => denied.push(reason)); + let seen = 0; + const next = async () => { + await flush(); + await vi.advanceTimersByTimeAsync(10_000); + const texts = context.radio.sent.slice(seen).map((m) => m.text); + seen = context.radio.sent.length; + return texts; + }; + return { ...context, denied, next }; +} + +describe('maxHops', () => { + it('validates the option', () => { + const transport = new MockTransport(); + expect(() => new Client({ transport, logger: silentLogger, maxHops: -1 })).toThrow(RangeError); + expect(() => new Client({ transport, logger: silentLogger, maxHops: 1.5 })).toThrow(RangeError); + expect(new Client({ transport, logger: silentLogger }).maxHops).toBeNull(); + expect(new Client({ transport, logger: silentLogger, maxHops: 0 }).maxHops).toBe(0); + }); + + it('ignores commands from beyond maxHops, silently, in DM and on channels', async () => { + const { client, radio, denied, next } = await setup(2); + client.register(ping); + await client.login(); + radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: '@TrainBot ping', hops: 3 }); + radio.receiveContactMessage({ from: julie.publicKey, text: '/ping', hops: 3 }); + radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: '@TrainBot ping', hops: 2 }); + radio.receiveContactMessage({ from: julie.publicKey, text: '/ping' }); + expect(await next()).toEqual(['@[Léa] pong', 'pong']); + expect(denied).toEqual([ + { type: 'tooFar', hopCount: 3 }, + { type: 'tooFar', hopCount: 3 }, + ]); + }); + + it('lets an unknown route through', async () => { + const { client, radio, next } = await setup(0); + client.register(ping); + await client.login(); + radio.receiveContactMessage({ from: julie.publicKey, text: '/ping', hops: 0xff }); + expect(await next()).toEqual(['pong']); + }); + + it('refuses message.reply() from an event but still delivers the message', async () => { + const { client, radio, next } = await setup(1); + const seen: Array<[number | null, boolean]> = []; + const errors: unknown[] = []; + client.register( + new EventBuilder().setEvent('messageCreate').setHandler(async (_client, message) => { + seen.push([message.hopCount, message.tooFar]); + await message.reply('echo').catch((e) => errors.push(e)); + }), + ); + await client.login(); + radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text: 'hi', hops: 4 }); + expect(await next()).toEqual([]); + expect(seen).toEqual([[4, true]]); + expect(errors[0]).toBeInstanceOf(TooFarError); + expect(errors[0]).toMatchObject({ code: 'TOO_FAR', hopCount: 4, maxHops: 1 }); + }); + + it('does not limit anything when unset', async () => { + const { client, radio, next } = await setup(); + client.register(ping); + await client.login(); + radio.receiveContactMessage({ from: julie.publicKey, text: '/ping', hops: 40 }); + expect(await next()).toEqual(['pong']); + }); +}); diff --git a/packages/client/test/public-channel.test.ts b/packages/client/test/public-channel.test.ts new file mode 100644 index 0000000..9df6986 --- /dev/null +++ b/packages/client/test/public-channel.test.ts @@ -0,0 +1,136 @@ +import { PUBLIC_CHANNEL_SECRET } from '@meshcorejs/protocol'; +import { fakeContactRecord } from '@meshcorejs/transports/mock'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CommandBuilder } from '../src/commands/command-builder.js'; +import type { DenyReason } from '../src/commands/context.js'; +import { PublicChannelError } from '../src/errors.js'; +import { EventBuilder } from '../src/events/event-builder.js'; +import { flush, setupClient } from './helpers.js'; + +beforeEach(() => { + vi.useFakeTimers({ now: new Date('2026-09-22T12:00:00Z') }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const julie = fakeContactRecord({ name: 'Julie' }); +export const publicChannel = { index: 0, name: 'Public', secret: PUBLIC_CHANNEL_SECRET }; +export const lyon = { index: 1, name: '#lyon', secret: new Uint8Array(16) }; + +export async function setup() { + const context = await setupClient({ + self: { name: 'TrainBot' }, + contacts: [julie], + channels: [publicChannel, lyon], + login: false, + }); + const denied: DenyReason[] = []; + context.client.on('commandDenied', (_ctx, reason) => denied.push(reason)); + let seen = 0; + + const next = async () => { + await flush(); + await vi.advanceTimersByTimeAsync(10_000); + const texts = context.radio.sent.slice(seen).map((m) => m.text); + seen = context.radio.sent.length; + return texts; + }; + const onPublic = (text: string) => context.radio.receiveChannelMessage({ channelIndex: 0, senderName: 'Léa', text }); + const onLyon = (text: string) => context.radio.receiveChannelMessage({ channelIndex: 1, senderName: 'Léa', text }); + return { ...context, denied, next, onPublic, onLyon, julie }; +} + +const ping = new CommandBuilder().setName('ping').setHandler((ctx) => ctx.reply('pong')); +const info = new CommandBuilder() + .setName('info') + .setScope('public') + .setHandler((ctx) => ctx.reply('TrainBot, ask me in DM')); +const everywhere = new CommandBuilder() + .setName('everywhere') + .setScope('dm', 'channel', 'public') + .setHandler((ctx) => ctx.reply('here')); + +describe("scope 'public'", () => { + it('ignores a default command on Public but runs it on another channel', async () => { + const { client, denied, next, onPublic, onLyon } = await setup(); + client.register(ping); + await client.login(); + onPublic('@TrainBot ping'); + expect(await next()).toEqual([]); + expect(denied).toEqual([{ type: 'scope' }]); + onLyon('@TrainBot ping'); + expect(await next()).toEqual(['@[Léa] pong']); + }); + + it("runs a setScope('public') command only on Public", async () => { + const { client, denied, next, onPublic, onLyon, radio } = await setup(); + client.register(info); + await client.login(); + onPublic('@TrainBot info'); + expect(await next()).toEqual(['@[Léa] TrainBot, ask me in DM']); + onLyon('@TrainBot info'); + expect(await next()).toEqual([]); + radio.receiveContactMessage({ from: julie.publicKey, text: '/info' }); + expect(await next()).toEqual([]); + expect(denied).toEqual([{ type: 'scope' }, { type: 'scope' }]); + }); + + it("answers everywhere with setScope('dm', 'channel', 'public')", async () => { + const { client, next, onPublic, onLyon, radio } = await setup(); + client.register(everywhere); + await client.login(); + onPublic('@TrainBot everywhere'); + onLyon('@TrainBot everywhere'); + radio.receiveContactMessage({ from: julie.publicKey, text: '/everywhere' }); + expect((await next()).sort()).toEqual(['@[Léa] here', '@[Léa] here', 'here']); + }); + + it("lists only 'public' commands in the helper on Public", async () => { + const { client, next, onPublic } = await setup(); + client.register([ping, info, everywhere]); + await client.login(); + onPublic('@TrainBot'); + const [line] = await next(); + expect(line).toContain('info'); + expect(line).toContain('everywhere'); + expect(line).not.toContain('ping'); + }); +}); + +describe('the only door to Public is ctx.reply()', () => { + it('refuses channel.send() on Public, from anywhere', async () => { + const { client, next } = await setup(); + await client.login(); + const pub = client.channels.get(0)!; + await expect(pub.send('hello world')).rejects.toBeInstanceOf(PublicChannelError); + await expect(pub.send('hello world')).rejects.toMatchObject({ code: 'PUBLIC_CHANNEL' }); + await expect(client.channels.get(1)!.send('hello lyon')).resolves.toBeDefined(); + expect(await next()).toEqual(['hello lyon']); + }); + + it('refuses message.reply() from an event on Public, allows it elsewhere', async () => { + const { client, next, onPublic, onLyon } = await setup(); + const errors: unknown[] = []; + client.register( + new EventBuilder() + .setEvent('messageCreate') + .setHandler((_client, message) => message.reply(`echo ${message.content}`).catch((e) => errors.push(e))), + ); + await client.login(); + onPublic('hi'); + onLyon('hi'); + expect(await next()).toEqual(['@[Léa] echo hi']); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(PublicChannelError); + }); + + it("lets ctx.reply() through for a 'public' command (see scope tests) and stays silent for the helper when no command is public", async () => { + const { client, next, onPublic } = await setup(); + client.register(ping); + await client.login(); + onPublic('@TrainBot'); + expect(await next()).toEqual([]); + }); +}); diff --git a/packages/client/test/radio-request.test.ts b/packages/client/test/radio-request.test.ts new file mode 100644 index 0000000..0ad3c29 --- /dev/null +++ b/packages/client/test/radio-request.test.ts @@ -0,0 +1,49 @@ +import { CommandCode } from '@meshcorejs/protocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GuardedCommandError, RadioError } from '../src/errors.js'; +import { expectType } from '../src/radio/radio.js'; +import { setupClient } from './helpers.js'; + +beforeEach(() => { + vi.useFakeTimers({ now: new Date('2026-09-22T12:00:00Z') }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('radio.request()', () => { + it('sends an arbitrary Companion command through the request queue and decodes its answer', async () => { + const { client, radio } = await setupClient(); + const before = radio.commands.length; + const batt = await client.radio.request( + CommandCode.GetBattAndStorage, + new Uint8Array(0), + expectType('battAndStorage', (f) => f), + ); + expect(batt.type).toBe('battAndStorage'); + expect(radio.commands.slice(before)).toEqual([CommandCode.GetBattAndStorage]); + }); + + it('defaults to expecting Ok and turns Err into RadioError', async () => { + const { client } = await setupClient(); + await expect(client.radio.request(0x7e)).rejects.toBeInstanceOf(RadioError); + await expect(client.radio.request(0x7e)).rejects.toMatchObject({ command: 'CMD_0x7e' }); + }); + + it('refuses the three commands that transmit on the mesh, without touching the transport', async () => { + const { client, radio } = await setupClient(); + const before = radio.commands.length; + for (const code of [CommandCode.SendTxtMsg, CommandCode.SendChannelTxtMsg, CommandCode.SendSelfAdvert]) { + await expect(client.radio.request(code, new Uint8Array(4))).rejects.toBeInstanceOf(GuardedCommandError); + await expect(client.radio.request(code, new Uint8Array(4))).rejects.toMatchObject({ code: 'GUARDED_COMMAND' }); + } + expect(radio.commands).toHaveLength(before); + }); + + it('validates the code byte', async () => { + const { client } = await setupClient(); + await expect(client.radio.request(256)).rejects.toBeInstanceOf(RangeError); + await expect(client.radio.request(-1)).rejects.toBeInstanceOf(RangeError); + }); +}); diff --git a/packages/client/test/replies.test.ts b/packages/client/test/replies.test.ts index ae18c30..426a81b 100644 --- a/packages/client/test/replies.test.ts +++ b/packages/client/test/replies.test.ts @@ -12,7 +12,7 @@ describe('Replies presets', () => { expect(englishReplies.cooldown(20)).toBe('⏳ Try again in 20s'); expect(englishReplies.invalidArgument('numero', '/train ')).toBe('⚠️ numero is invalid\n/train '); expect(frenchReplies.unknownCommandDM).toBe('❓ Commande inconnue, /help'); - expect(frenchReplies.channelUntrusted).toBe('↪️ Envoie-moi cette commande en DM'); + expect(frenchReplies.helperChannel(['train'])).toBe('Commandes : train · /help en DM'); expect(frenchReplies.cooldown(20)).toBe('⏳ Réessaie dans 20s'); expect(frenchReplies.invalidArgument('numero', '/train ')).toBe('⚠️ numero invalide\n/train '); }); diff --git a/packages/client/test/send-queue.test.ts b/packages/client/test/send-queue.test.ts index d0fe37c..c3b77a0 100644 --- a/packages/client/test/send-queue.test.ts +++ b/packages/client/test/send-queue.test.ts @@ -2,7 +2,8 @@ import { CommandCode } from '@meshcorejs/protocol'; import { fakeContactRecord } from '@meshcorejs/transports/mock'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MessageBuilder } from '../src/builders/message-builder.js'; -import { DeliveryFailedError, MessageTooLongError } from '../src/errors.js'; +import { DeliveryFailedError, MessageTooLongError, RateLimitError } from '../src/errors.js'; +import { CHANNEL_SEND_LIMIT, CHANNEL_SEND_WINDOW_MS } from '../src/messages/send-queue.js'; import type { SentMessage } from '../src/messages/sent-message.js'; import { flush, setupClient } from './helpers.js'; @@ -170,6 +171,81 @@ describe('replies', () => { }); }); +describe('channel rate limit', () => { + it('accepts 10 parts per channel per 5 minutes, then refuses without queueing', async () => { + const { client, radio, channel } = await setup(); + const failed = vi.fn(); + client.on('messageFailed', failed); + for (let i = 0; i < CHANNEL_SEND_LIMIT; i++) { + const sending = channel.send(`msg ${i}`); + await vi.advanceTimersByTimeAsync(2000); + await expect(sending).resolves.toBeDefined(); + } + const refused = channel.send('one too many'); + await expect(refused).rejects.toBeInstanceOf(RateLimitError); + await expect(refused).rejects.toMatchObject({ + code: 'RATE_LIMIT', + resource: 'channel', + channel, + limit: CHANNEL_SEND_LIMIT, + windowMs: CHANNEL_SEND_WINDOW_MS, + }); + expect(radio.sent).toHaveLength(CHANNEL_SEND_LIMIT); + expect(failed).not.toHaveBeenCalled(); + }); + + it('reports when the next part frees up and accepts again after the window', async () => { + const { channel } = await setup(); + for (let i = 0; i < CHANNEL_SEND_LIMIT; i++) { + const sending = channel.send(`msg ${i}`); + await vi.advanceTimersByTimeAsync(2000); + await sending; + } + await expect(channel.send('x')).rejects.toMatchObject({ retryAfterMs: CHANNEL_SEND_WINDOW_MS - 20_000 }); + await vi.advanceTimersByTimeAsync(CHANNEL_SEND_WINDOW_MS - 20_000); + const sending = channel.send('again'); + await vi.advanceTimersByTimeAsync(2000); + await expect(sending).resolves.toBeDefined(); + }); + + it('counts parts and refuses a split message that does not fit whole', async () => { + const { channel } = await setup(); + for (let i = 0; i < CHANNEL_SEND_LIMIT - 2; i++) { + const sending = channel.send(`msg ${i}`); + await vi.advanceTimersByTimeAsync(2000); + await sending; + } + const three = new MessageBuilder() + .addLines(Array.from({ length: 40 }, (_, i) => `ligne ${i} un peu longue pour forcer`)) + .setOverflow('split', { maxParts: 3 }); + await expect(channel.send(three)).rejects.toBeInstanceOf(RateLimitError); + const sending = channel.send('fits'); + await vi.advanceTimersByTimeAsync(2000); + await expect(sending).resolves.toBeDefined(); + }); + + it('keeps one budget per channel and none for DMs', async () => { + const { client, radio, channel, contact } = await setup({ + channels: [lyon, { index: 2, name: '#paris', secret: new Uint8Array(16) }], + }); + for (let i = 0; i < CHANNEL_SEND_LIMIT; i++) { + const sending = channel.send(`msg ${i}`); + await vi.advanceTimersByTimeAsync(2000); + await sending; + } + const paris = client.channels.get('#paris')!; + const onParis = paris.send('hello'); + await vi.advanceTimersByTimeAsync(2000); + await expect(onParis).resolves.toBeDefined(); + for (let i = 0; i < 12; i++) { + const sending = contact.send(`dm ${i}`); + await vi.advanceTimersByTimeAsync(2000); + await expect(sending).resolves.toBeDefined(); + } + expect(radio.sent.filter((m) => m.kind === 'dm')).toHaveLength(12); + }); +}); + describe('outages and shutdown', () => { it('holds messages while disconnected and sends them after reconnecting', async () => { const { radio, transport, contact } = await setup(); diff --git a/packages/protocol/AGENTS.md b/packages/protocol/AGENTS.md index 27a2681..2e41d39 100644 --- a/packages/protocol/AGENTS.md +++ b/packages/protocol/AGENTS.md @@ -52,7 +52,8 @@ test/ `encodePush` for every push. - **Constants**: `CommandCode`, `ResponseCode`, `PushCode`, `RadioErrorCode`, `ContactType`, `TxtType`, `MAX_FRAME_SIZE = 176`, `MAX_TEXT_LEN = 160`, `PUB_KEY_SIZE = 32`, `PUB_KEY_PREFIX_SIZE = 6`, - `MAX_PATH_SIZE = 64`, `NAME_FIELD_SIZE = 32`, `CHANNEL_SECRET_SIZE = 16`, `OUT_PATH_UNKNOWN = 0xff`, + `MAX_PATH_SIZE = 64`, `NAME_FIELD_SIZE = 32`, `CHANNEL_SECRET_SIZE = 16`, `PUBLIC_CHANNEL_SECRET` + (the built-in Public channel's secret, identify Public by it, never by name), `OUT_PATH_UNKNOWN = 0xff`, `APP_TARGET_VERSION = 3`, `MIN_FIRMWARE_VERSION = 3`. - **Helpers**: `ByteReader`, `ByteWriter`, `toHex`, `fromHex`, `utf8ByteLength`, key/path/contact-URI helpers listed in Layout. diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts index 7249bd3..6e0b694 100644 --- a/packages/protocol/src/constants.ts +++ b/packages/protocol/src/constants.ts @@ -96,6 +96,13 @@ export const MAX_TEXT_LEN = 160; export const NAME_FIELD_SIZE = 32; /** A channel secret, in bytes. */ export const CHANNEL_SECRET_SIZE = 16; +/** + * Secret of the "Public" channel every Companion radio ships with (`PUBLIC_GROUP_PSK` in the firmware's + * `examples/companion_radio/MyMesh.cpp`). Identify Public by this secret, never by its name. + */ +export const PUBLIC_CHANNEL_SECRET: Uint8Array = Uint8Array.from([ + 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, +]); /** The `pathLen` meaning the route to a contact is unknown. */ export const OUT_PATH_UNKNOWN = 0xff; /** The Companion protocol version this codec speaks. */ diff --git a/packages/protocol/test/index.test.ts b/packages/protocol/test/index.test.ts index 47b3943..e9ff39c 100644 --- a/packages/protocol/test/index.test.ts +++ b/packages/protocol/test/index.test.ts @@ -19,6 +19,7 @@ describe('public API', () => { 'MIN_FIRMWARE_VERSION', 'NAME_FIELD_SIZE', 'OUT_PATH_UNKNOWN', + 'PUBLIC_CHANNEL_SECRET', 'PUB_KEY_PREFIX_SIZE', 'PUB_KEY_SIZE', 'PushCode', diff --git a/packages/protocol/test/public-channel.test.ts b/packages/protocol/test/public-channel.test.ts new file mode 100644 index 0000000..f733567 --- /dev/null +++ b/packages/protocol/test/public-channel.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { CHANNEL_SECRET_SIZE, PUBLIC_CHANNEL_SECRET } from '../src/index.js'; + +describe('PUBLIC_CHANNEL_SECRET', () => { + it('is the firmware PUBLIC_GROUP_PSK, decoded from its base64 form', () => { + // examples/companion_radio/MyMesh.cpp: #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" + const expected = Uint8Array.from(Buffer.from('izOH6cXN6mrJ5e26oRXNcg==', 'base64')); + expect(PUBLIC_CHANNEL_SECRET).toHaveLength(CHANNEL_SECRET_SIZE); + expect(Buffer.from(PUBLIC_CHANNEL_SECRET).toString('hex')).toBe('8b3387e9c5cdea6ac9e5edbaa115cd72'); + expect(PUBLIC_CHANNEL_SECRET).toEqual(expected); + }); +}); diff --git a/packages/testing/AGENTS.md b/packages/testing/AGENTS.md index 3523e1b..0a97a3b 100644 --- a/packages/testing/AGENTS.md +++ b/packages/testing/AGENTS.md @@ -31,6 +31,7 @@ const bot = await createTestClient({ load?, self?, now?, maxChannels? = 8, radio bot.client; bot.radio; bot.transport; bot.clock // underlying objects bot.fakeContact(name, overrides?): Contact // adds a contact to the radio and the cache bot.fakeChannel(name, secret?): Channel // first free slot +bot.fakeChannel('Public', PUBLIC_CHANNEL_SECRET): Channel // a Public channel (secret from '@meshcorejs/protocol') bot.setRoleMembers(role, [contact | key]): void // overrides the role's member source for the test await bot.dm(contact | name, '/cmd …'): string[] // texts sent by the bot while handling it await bot.channel('#name', authorName, '@Bot cmd …'): string[] diff --git a/packages/testing/test/fixtures/bot/commands/info.ts b/packages/testing/test/fixtures/bot/commands/info.ts new file mode 100644 index 0000000..295c844 --- /dev/null +++ b/packages/testing/test/fixtures/bot/commands/info.ts @@ -0,0 +1,7 @@ +import { CommandBuilder } from '@meshcorejs/client'; + +export default new CommandBuilder() + .setName('info') + .setDescription('Who I am, allowed on Public') + .setScope('dm', 'channel', 'public') + .setHandler((ctx) => ctx.reply('TrainBot · /help in DM')); diff --git a/packages/testing/test/test-client.test.ts b/packages/testing/test/test-client.test.ts index 266b853..54606a0 100644 --- a/packages/testing/test/test-client.test.ts +++ b/packages/testing/test/test-client.test.ts @@ -1,4 +1,5 @@ import { frenchReplies, RadioConfig } from '@meshcorejs/client'; +import { PUBLIC_CHANNEL_SECRET } from '@meshcorejs/protocol'; import { afterEach, describe, expect, it } from 'vitest'; import { createTestClient, fakeContactKey, parseDuration, type TestBot } from '../src/index.js'; @@ -40,7 +41,7 @@ describe('createTestClient', () => { it('returns channel replies and creates the channel on demand', async () => { const b = await bot(); - expect(await b.channel('#lyon', 'Léa', '@TrainBot')).toEqual(['@[Léa] @TrainBot slow']); + expect(await b.channel('#lyon', 'Léa', '@TrainBot')).toEqual(['@[Léa] Commands: info, slow · DM me /help']); expect(b.client.channels.get('#lyon')).toBeDefined(); }); @@ -92,3 +93,13 @@ describe('createTestClient', () => { expect(await b.dm(b.fakeContact('Léa'), '/secret')).toEqual(['⛔ Permission refusée']); }); }); + +describe('anti-spam rules, seen from a bot test', () => { + it('ignores default commands on Public and answers opted-in ones', async () => { + const b = await bot(); + b.fakeChannel('Public', PUBLIC_CHANNEL_SECRET); + expect(await b.channel('Public', 'Léa', '@TrainBot slow')).toEqual([]); + expect(await b.channel('Public', 'Léa', '@TrainBot info')).toEqual(['@[Léa] TrainBot · /help in DM']); + expect(await b.channel('Public', 'Léa', '@TrainBot nope')).toEqual([]); + }); +}); diff --git a/packages/transports/AGENTS.md b/packages/transports/AGENTS.md index 0c699ff..0886898 100644 --- a/packages/transports/AGENTS.md +++ b/packages/transports/AGENTS.md @@ -50,8 +50,10 @@ interface Transport extends TypedEmitter<{ frame: [Uint8Array]; close: [Error?] `channels`, `clock`, `suggestedTimeoutMs`, `selfAdvertPacket`), `AckMode = 'auto' | 'manual' | 'never'`, `FakeSentMessage` (`kind: 'dm' | 'channel'`), `fakeContactRecord(partial)`. - `FakeRadio` runtime knobs: `attach(transport)`, `sent[]`, `commands[]`, `ackMode`, `ackDelayMs`, - `unresponsive`, `batteryMillivolts`, `receiveContactMessage()`, `receiveChannelMessage()`, `hearAdvert()`, - `updatePath()`, `deleteContact()`, `ack(expectedAck, roundTripMs)`. + `unresponsive`, `refuseSelfAdvert` (when true, `SEND_SELF_ADVERT` answers `Err BadState` instead of `Ok` — + for testing a firmware refusal, as opposed to `unresponsive`'s timeout), `batteryMillivolts`, + `receiveContactMessage()`, `receiveChannelMessage()`, `hearAdvert()`, `updatePath()`, `deleteContact()`, + `ack(expectedAck, roundTripMs)`. ## Rules diff --git a/packages/transports/src/mock/fake-radio.ts b/packages/transports/src/mock/fake-radio.ts index 01dd9f4..bd35314 100644 --- a/packages/transports/src/mock/fake-radio.ts +++ b/packages/transports/src/mock/fake-radio.ts @@ -100,6 +100,7 @@ export class FakeRadio { ackMode: AckMode = 'auto'; ackDelayMs = 50; unresponsive = false; + refuseSelfAdvert = false; batteryMillivolts = 4100; suggestedTimeoutMs: number; selfAdvertPacket: Uint8Array; @@ -293,6 +294,7 @@ export class FakeRadio { } case CommandCode.SendSelfAdvert: + if (this.refuseSelfAdvert) return this.#reply(encodeErrResponse(RadioErrorCode.BadState)); return this.#reply(encodeOkResponse()); case CommandCode.AddUpdateContact: { From d80fa790de177bcf686b5342eba9329c5d1098c5 Mon Sep 17 00:00:00 2001 From: theolagache <43389096+Asuniia@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:09:33 +0200 Subject: [PATCH 2/3] fix: missing message --- packages/client/src/commands/command.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/src/commands/command.ts b/packages/client/src/commands/command.ts index fd568d0..e2d2b5e 100644 --- a/packages/client/src/commands/command.ts +++ b/packages/client/src/commands/command.ts @@ -4,7 +4,10 @@ import type { ArgDefinition } from './args.js'; import type { CommandContext } from './context.js'; import type { ArgumentError } from './parse-args.js'; - +/** + * Where a command may be triggered: a direct message, a channel other than Public, or the Public channel. + * Public is never included by default — a command must opt in with `setScope('public')`. + */ export type CommandScope = 'dm' | 'channel' | 'public'; /** `CommandBuilder.setHandler()`'s callback, run once trigger, scope, permissions and cooldown pass. */ From 22dd2f34a79621da506fd3e4bdef7ce07a72d6ec Mon Sep 17 00:00:00 2001 From: theolagache <43389096+Asuniia@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:11:11 +0200 Subject: [PATCH 3/3] fix: typos --- _typos.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/_typos.toml b/_typos.toml index a6abbaa..416b9be 100644 --- a/_typos.toml +++ b/_typos.toml @@ -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"]