Help us shape the future of Go! Take the 2026 Go Developer Survey

meshcore-bot

command module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: MIT Imports: 41 Imported by: 0

README

meshcore-bot

A configurable bot framework for MeshCore mesh networks, built with the pure Go meshcore-go library.

Features

  • Trigger-based architecture: Respond to group messages, private channel messages, or on a cron schedule.
  • Go template responses: Access mesh data like sender, hops, path hashes, SNR, RSSI, and more.
  • Three kinds of radio: MeshCore KISS firmware over USB or TCP, openHop Modem firmware over USB or TCP, or a bare SX1262 hat on a Raspberry Pi's SPI bus. The same modems OwlShack supports, publishing the same MQTT schema.
  • Private channel support: Join private channels using a hex-encoded PSK.
  • MQTT integration: Publish observed mesh traffic to MQTT brokers (e.g. LetsMesh, CoreScope).
  • Hot-reload: Reload configuration via SIGHUP without restarting. Reconnects the modem if connection settings change.
  • Radio reconnect: If the radio is unplugged, its link drops, or it stops answering, the bot reconnects on its own and keeps retrying until the radio is back.
  • Multi-bot support: Run multiple bots within a single instance.
  • Flexible configuration: Supports TOML, YAML, and JSON formats.

Installation

Pre-built binaries are available for Linux, macOS, and Windows on the Releases page.

  1. Go to the latest release.
  2. Download the binary for your platform (e.g. meshcore-bot-linux-arm64 for a Raspberry Pi).
  3. Make it executable and move it to a folder of your choosing:
chmod +x meshcore-bot-linux-arm64
sudo mv meshcore-bot-linux-arm64 /usr/local/bin/meshcore-bot
Docker

Images are published to ghcr.io/meshcore-go/meshcore-bot for the following platforms: linux/386, linux/amd64, linux/arm/v6, linux/arm/v7, linux/arm64/v8, linux/ppc64le, linux/riscv64, linux/s390x.

docker pull ghcr.io/meshcore-go/meshcore-bot:latest
Build from Source

Requires Go 1.26.1+.

git clone https://github.com/meshcore-go/meshcore-bot.git
cd meshcore-bot
go build -o meshcore-bot

Running

The bot looks for config.toml, config.yaml, config.yml, or config.json in the current directory.

Step 1: Plug in your radio

Connect your MeshCore radio to your computer via USB. On Linux it usually shows up as /dev/ttyACM0. On macOS it's something like /dev/cu.usbmodem*.

On Linux, your user needs permission to access serial devices. Add yourself to the dialout group:

sudo usermod -a -G dialout $USER

Log out and back in (or reboot) for the change to take effect.

Step 2: Create a config file

Create a file called config.toml in the same folder as the binary. Here's a minimal example that responds to "ping" on the #testing channel:

connection = "serial:///dev/ttyACM0"

freq = 917.375
bw = 62.50
sf = 7
cr = 8
tx = 2

[[bot]]
name = "My Bot"

[[bot.trigger]]
type = "channel"
template = "Pong! Hello {{.Sender}}"
channels = ["#testing"]
match = ["(?i)^ping"]
Step 3: Run it
./meshcore-bot

That's it. The bot will connect to your radio, join the #testing channel, and reply "Pong! Hello <sender>" whenever someone sends a message starting with "ping".

Using Docker

Mount your config file and pass through the serial device:

docker run -d \
  --device /dev/ttyACM0 \
  -v ./config.toml:/data/config.toml \
  ghcr.io/meshcore-go/meshcore-bot

For TCP connections (e.g. a serial-to-TCP bridge to the KISS device), no --device is needed:

docker run -d \
  -v ./config.toml:/data/config.toml \
  ghcr.io/meshcore-go/meshcore-bot

Pass CLI flags directly:

docker run -d \
  --device /dev/ttyACM0 \
  -v ./my-config.toml:/my-config.toml \
  ghcr.io/meshcore-go/meshcore-bot \
  meshcore-bot -c /my-config.toml -vvv

Configuration Reference

Connection
Field Description Default
connection Which radio, chosen by scheme. See Radios below. serial:///dev/ttyACM0
baudRate Serial baud rate for KISS. Ignored for openHop, whose firmware is fixed at 921600. 115200
spiBoard The SPI hat, by name. Required for spi://.
modemToken Password for an openHop modem over TCP. Kept out of connection so it is not logged with it.
nodeType Ignored. Kept so older configs still load; the connection scheme picks the radio.
logLevel Log level: debug, info, warn, error, trace (overridden by -v flags) info
Radio Settings
Field Description Default
freq Frequency in MHz 917.375
bw Bandwidth in kHz 62.50
sf Spreading Factor 7
cr Coding Rate 8
tx TX Power 2
dutyCycle Transmit duty cycle as a percentage, greater than 0 and up to 100. Named after the firmware's set dutycycle, but fractions are allowed: 0.1 is valid, which firmware's own knob rejects. Check your region's limit: EU868 sub-bands are capped at 1 or 0.1, while NZ/AU 915-928 MHz has no duty-cycle limit. Applies process-wide (one radio mux serves every bot and the observer). 50
Radios

The connection scheme picks the radio:

Connection Radio
serial:///dev/ttyACM0, tcp://host:port MeshCore KISS modem firmware
openhop:///dev/ttyUSB0, openhop://host:port openHop Modem firmware. Over TCP, set modemToken if the modem has one. The driver reconnects on its own if the link drops.
spi:// A bare SX1262 on this host's SPI bus, with no firmware in front of it: the bot is the radio stack. Needs spiBoard.

SPI hats. The pins come from a named preset, because a wrong pin gives a radio that is silently dead rather than one that errors. spi:// on its own uses the board's own SPI device; spi://SPI0.1 or spi:///dev/spidev0.1 overrides it, with a warning if it disagrees with the board. tx is checked against the module's rating and refused above it rather than clamped. Boards marked community log a warning at startup: their pin maps come from other projects and have not been run on the hat here.

spiBoard Hat Max TX Pins
bq-station-g3 BQ Voyage Station G3 19 dBm community
meshadv MeshAdv 22 dBm community
nebra-duo-hat NebraDuo-E22P-1W 18 dBm community
nebrahat NebraHat-2W 8 dBm community
pimesh-1w-v1 PiMesh-1W (V1) 18 dBm community
pimesh-1w-v2 PiMesh-1W (V2) 18 dBm community
rak6421-13300x-slot1 RAK6421 with RAK1330x, IO slot 1 22 dBm hardware
rak6421-13300x-slot2 RAK6421 with RAK1330x, IO slot 2 22 dBm hardware
uconsole-aio-v2 uConsole LoRa Module aio v2 22 dBm community
ultrapeaterzero-e22 Zindello UltraPeaterZero (E22, 1 W) 22 dBm hardware
ultrapeaterzero-e22p Zindello UltraPeaterZero (E22P, 1 W) 22 dBm hardware
waveshare Waveshare LoRa HAT 22 dBm community
zebra ZebraHat-1W 18 dBm community
zebra-duo-hat-r0 ZebraHatDuo-R0-1W 18 dBm community
zebra-duo-hat-r1 ZebraHatDuo-R1-1W 18 dBm community

To add a hat, or correct a pin map without waiting for a release, put a boards.json in the working directory. Entries are merged over the presets by name and re-read on SIGHUP. The format is the one in modem_boards.json.

On Raspberry Pi OS, enable SPI (sudo raspi-config, Interface Options) and add your user to the spi and gpio groups. SPI has not been tested under Docker; run the binary on the Pi directly.

connection = "spi://"
spiBoard = "rak6421-13300x-slot1"
Triggers

Each bot has a name and an array of triggers. Every trigger has a type and a template.

Channels

The channels field accepts either plain channel names (for public/hashtag channels) or objects with a privateKey for private channels:

# Public channels — just use the name
channels = ["#general", "#testing"]

# Private channels — use the [[bot.trigger.channels]] syntax
[[bot.trigger.channels]]
name = "Secret Ops"
privateKey = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"

# Mix of Private channels — use the [[bot.trigger.channels]] syntax
[[bot.trigger.channels]]
name = "Secret Ops"
privateKey = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"

[[bot.trigger.channels]]
name = "#general"

[[bot.trigger.channels]]
name = "#testing"
Channel Trigger (type = "channel")

Fires when a message is received on a channel that matches one of the match patterns.

Field Description
channels Channels to listen on
match Array of Go regular expressions to match against incoming messages
template Go text/template for the response
retryTimeout Seconds to wait for a repeater echo before retrying
maxRetries Total sends, counting the first: 3 sends once and resends up to twice
charLimitBehaviour What to do when a message exceeds the character limit: "truncate" or "split"
pathHashSize Path hash size: 0 = copy sender's setting, 1/2/3 = bytes per hash
Cron Trigger (type = "cron")

Fires on a schedule.

Field Description
schedule Cron expression (e.g. "*/5 * * * *")
channels Channels to send the message to
template Go text/template for the message
retryTimeout Seconds to wait for a repeater echo before retrying
maxRetries Total sends, counting the first: 3 sends once and resends up to twice
charLimitBehaviour What to do when a message exceeds the character limit: "truncate" or "split"
pathHashSize Path hash size: 0 = copy sender's setting, 1/2/3 = bytes per hash

After sending a message, the bot listens for the message to be repeated back by a repeater. If no echo is heard within retryTimeout seconds, the message is sent again, until it has been sent maxRetries times in all. This applies to both channel and cron triggers.

Template Variables

Channel Trigger:

  • {{.Sender}} — Sender's node name
  • {{.Channel}} — Channel name
  • {{.Message}} — Original message text
  • {{.Match}} — Map of named regex capture groups
  • {{.Timestamp}} — Message timestamp
  • {{.SNR}} — Signal-to-Noise Ratio
  • {{.RSSI}} — Received Signal Strength Indicator
  • {{.Hops}} — Number of hops
  • {{.PathHashes}} — Raw path hashes
  • {{.PathHashSize}} — Size of path hashes

Cron Trigger:

  • {{.Time}} — Current time
  • {{.Schedule}} — The cron schedule string

Built-in Functions:

  • formatPathBytes — Formats raw path hashes into a readable string.

Example Configs

KISS Node with Private Channel
connection = "serial:///dev/ttyACM0"
baudRate = 115200

freq = 917.375
bw = 62.50
sf = 7
cr = 8
tx = 22

[[bot]]
name = "Ping Bot"

[[bot.trigger]]
type = "channel"
template = "@[{{.Sender}}] 🦈={{.PathHashSize}} 🦘={{.Hops}} 🛣️={{.PathHashes | formatPathBytes}}"
match = ["(?i)^test", "(?i)^ping"]

[[bot.trigger.channels]]
name = "MyPrivateChannel"
privateKey = "7d78eab105a663ab3504d99a0e5b1891"
Cron Trigger
[[bot.trigger]]
type = "cron"
schedule = "*/5 * * * *"
channels = ["#testing"]
template = "Periodic update: The time is {{.Time}}"

CLI Flags

Flag Description
-c, --config PATH Path to the configuration file
-V, --version Print version and exit
-v, --verbose Enable verbose debug logging

MQTT Integration

meshcore-bot can publish observed mesh traffic to MQTT brokers. This is used by services like LetsMesh to aggregate mesh network data.

MQTT lives under exactly one bot: define an optional [bot.mqtt] section on a single [[bot]]. At most one bot in the whole config may have it. A unique identity key file is used for authentication.

Configs from before this change (root-level [[observer]] blocks) are migrated automatically on first load: observers merge into the first bot's [bot.mqtt], and the original file is backed up alongside as config.toml.bak-<timestamp> (the rewrite loses comments). A config with both formats fails loudly instead of guessing.

[[bot]]
name = "AKL Bot"

[bot.mqtt]
name = "AKL Observer"
iataCode = "AKL"
keyFile = "mqtt_identity.key"
statusInterval = 300

[bot.mqtt.advert]
enabled = true
interval = 86400
lat = -36.8485
lon = 174.7633

[[bot.mqtt.broker]]
name = "US West (LetsMesh v1)"
enabled = true
transport = "wss"
host = "mqtt-us-v1.letsmesh.net"
port = 443
topicPrefix = "meshcore"
retainStatus = false
tlsEnabled = true
authType = "token"
audience = "mqtt-us-v1.letsmesh.net"

[[bot.mqtt.broker]]
name = "Europe (LetsMesh v1)"
enabled = true
transport = "wss"
host = "mqtt-eu-v1.letsmesh.net"
port = 443
topicPrefix = "meshcore"
retainStatus = false
tlsEnabled = true
authType = "token"
audience = "mqtt-eu-v1.letsmesh.net"

[[bot.mqtt.broker]]
name = "CoreScope NZ"
enabled = true
transport = "wss"
host = "meshcore-mqtt-1.baird.io"
port = 443
topicPrefix = "meshcore"
retainStatus = false
tlsEnabled = true
authType = "token"
audience = "meshcore-mqtt-1.baird.io"
MQTT Field Description
name Display name for this observer
iataCode Location identifier (e.g. airport code)
keyFile Path to the identity key file (created automatically if missing)
statusInterval Seconds between status publishes (default: 300)
owner Owner name included in MQTT token claims (optional)
email Email included in MQTT token claims (optional)
Broker Field Description
name Display name for this broker
enabled Enable/disable this broker
dedup Enable per-broker packet deduplication (default: false)
transport "wss" (WebSocket Secure) or "tcp"
host Broker hostname
port Broker port
path WebSocket path (default: none)
topicPrefix MQTT topic prefix
disallowedPacketTypes Packet types to exclude (e.g. ["ack", "advert"])
retainStatus Retain status messages on the broker
tlsEnabled Enable TLS
tlsInsecure Skip TLS certificate verification
authType "token", "basic", or "none"
username Username for basic auth
password Password for basic auth
audience Token audience (for token auth)
Advert Field Description
enabled Enable periodic advert broadcasting
interval Seconds between adverts (default: 86400 / once per day)
lat Latitude in decimal degrees (optional)
lon Longitude in decimal degrees (optional)

When enabled, the observer broadcasts a signed chat-node advert over the mesh on startup and then repeats at the configured interval. This allows the node to appear in the mesh network as a visible participant. If lat and lon are provided, the advert includes location data.

Hot Reload

Send a SIGHUP signal to the process to reload the configuration without restarting:

kill -SIGHUP $(pgrep meshcore-bot)

If the connection settings, radio parameters, spiBoard or modemToken change, the bot reconnects the radio.

Radio Reconnect

The bot reconnects the radio on its own, and restarts its bots and MQTT observer when the radio is back:

  • The link drops, such as a KISS modem unplugged, a TCP connection lost, or an SPI radio that stops receiving. It reacts straight away.
  • The radio stops answering while its port stays open, as wedged firmware can. The bot checks every 30 seconds and reconnects after 3 checks go unanswered, so about 90 seconds.

While the radio is down, the bot retries after 1 second, then waits twice as long each time, up to 30 seconds between tries. MQTT shows the node as offline until the radio is back. If the fix is a config change, such as a different port, send SIGHUP and it is used at once rather than at the next retry.

openHop Modems reconnect a dropped link inside their own driver, so for them only the answering check applies. If the radio can't be reached when the bot first starts, it exits with an error instead.

License

See LICENSE.

Documentation

The Go Gopher

There is no documentation for this package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL