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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 13 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ The easiest way to run Meshtastic Bot is using Docker. This method requires mini
```
MESHTASTIC_NODE_IP=your_meshtastic_node_ip
ADMIN_NODES=comma_separated_admin_node_ids
STORAGE_API_ROOT=your_storage_api_url
STORAGE_API_TOKEN=your_storage_api_token
STORAGE_API_ROOT=https://meshflow.pskillen.xyz
STORAGE_API_TOKEN=your_storage_api_token from above site
# Optionally, you can upload to a second API as well
STORAGE_API_2_ROOT=your_storage_api_2_url
STORAGE_API_2_TOKEN=your_storage_api_2_token
Expand All @@ -24,22 +24,13 @@ STORAGE_API_2_TOKEN=your_storage_api_2_token
### 2. Use This `docker-compose.yaml`

```yaml
version: '3.8'

services:
bot:
image: ghcr.io/pskillen/meshtastic-bot:latest
container_name: meshtastic-bot
restart: unless-stopped
environment:
- MESHTASTIC_IP=${MESHTASTIC_NODE_IP}
- ADMIN_NODES=${ADMIN_NODES}
- STORAGE_API_ROOT=${STORAGE_API_ROOT}
- STORAGE_API_TOKEN=${STORAGE_API_TOKEN}
- STORAGE_API_VERSION=2
- STORAGE_API_2_ROOT=${STORAGE_API_2_ROOT}
- STORAGE_API_2_TOKEN=${STORAGE_API_2_TOKEN}
- STORAGE_API_2_VERSION=2
env_file:
- meshtastic-bot.env
volumes:
- mesh_bot_data:/app/data

Expand Down Expand Up @@ -85,17 +76,18 @@ If you prefer to run the bot natively (e.g., for development or customization):

## Usage

The bot listens for messages and responds to commands. You can interact with it via supported Meshtastic channels.
The bot listens for messages and responds to commands as a direct message. You can interact with it via supported Meshtastic channels.

### Supported Commands

| Command | Description |
|-----------|------------------------------------------------|
| `!help` | Displays a list of available commands |
| `!hello` | Displays information about the bot |
| `!ping` | Responds with "Pong!" |
| `!nodes` | Displays a list of connected nodes, stats, etc |
| `!whoami` | Displays information about the sender |
| Command | Description |
|-----------|---------------------------------------------------------------|
| `!help` | Displays a list of available commands |
| `!hello` | Displays information about the bot |
| `!ping` | Responds with "Pong!" |
| `!nodes` | Displays a list of connected nodes, stats, etc |
| `!whoami` | Displays information about the sender |
| `!tr` | Responds with a hop count followed by the Traceroute |

---

Expand Down
17 changes: 14 additions & 3 deletions src/api/StorageAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import traceback
from datetime import datetime
from json import JSONDecodeError
from pathlib import Path
from typing import Union

Expand Down Expand Up @@ -64,6 +65,12 @@ def store_raw_packet(self, packet: dict):
"""
Store a raw packet in the storage API
"""
# Filter out packet types that the API doesn't support or we don't want to store
ignored_ports = [345, 'ROUTING_APP', 'TRACEROUTE_APP', 'ADMIN_APP']
portnum = packet.get('decoded', {}).get('portnum')
if portnum in ignored_ports:
return

# Convert bytes to Base64-encoded strings recursively
raw_packet: MeshPacket = packet.get('raw')
packet = StorageAPIWrapper._sanitise_raw_packet(packet)
Expand All @@ -85,9 +92,13 @@ def store_raw_packet(self, packet: dict):
self._dump_failed_packet(packet, ex)
return

logging.debug(f"Response: {response.json()}")

return response.json()
try:
response_json = response.json()
logging.debug(f"Response: {response_json}")
return response_json
except JSONDecodeError:
logging.debug(f"Response (not JSON): {response.text}")
return {'text': response.text}

def list_nodes(self) -> list[MeshNode]:
"""
Expand Down
70 changes: 70 additions & 0 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ def __init__(self, address: str):
self.command_logger = None
self.user_prefs_persistence = None
self.storage_apis = []
self.pending_traces = {}

pub.subscribe(self.on_receive, "meshtastic.receive")
pub.subscribe(self.on_traceroute, "meshtastic.traceroute")
pub.subscribe(self.on_receive_text, "meshtastic.receive.text")
pub.subscribe(self.on_node_updated, "meshtastic.node.updated")
pub.subscribe(self.on_connection, "meshtastic.connection.established")
Expand Down Expand Up @@ -150,6 +152,25 @@ def handle_public_message(self, packet: MeshPacket):
from_id = packet['fromId']
sender = self.node_db.get_by_id(from_id)

logging.info(f"DEBUG: Received public message from {sender.long_name if sender else from_id}: {message}")

# Allow !tr in public channels
words = message.split()
if words and words[0].lower() == "!tr":
logging.info(f"Received public !tr from {sender.long_name if sender else from_id}")
# Import here to avoid circular imports if any, though factory is better
from src.commands.factory import CommandFactory
command_instance = CommandFactory.create_command("!tr", self)
if command_instance:
try:
# By default commands reply in DM (reply_in_dm).
# If we want public reply, we'd need to modify the command or use reply_in_channel.
# But for now, let's just let it run. It will DM the user back (which is cleaner).
command_instance.handle_packet(packet)
return # Stop processing responders
except Exception as e:
logging.error(f"Error handling public command: {e}")

responder = ResponderFactory.match_responder(message, self)
if responder:
try:
Expand All @@ -162,7 +183,56 @@ def handle_public_message(self, packet: MeshPacket):
except Exception as e:
logging.error(f"Error handling message: {e}")

def on_traceroute(self, packet, route):
"""Callback for when a traceroute response is received."""
target_id = packet.get('fromId')

if target_id not in self.pending_traces:
logging.debug(f"Received traceroute from {target_id} but no pending request found.")
return

requester_id = self.pending_traces.pop(target_id)

# Format the OUTBOUND route
route_ids = route.route
hops = []
for node_id_int in route_ids:
# Convert int to !hex string
node_id_str = f"!{node_id_int:08x}"
node = self.node_db.get_by_id(node_id_str)
if node:
hops.append(f"{node.short_name}")
else:
hops.append(f"{node_id_str}")

route_str = " -> ".join(hops) if hops else "Direct (or unknown)"

response_out = f"Trace TO {target_id} ({len(hops)} hops):\n{route_str}"
logging.info(f"Sending traceroute OUT result to {requester_id}: {response_out}")
self.interface.sendText(response_out, destinationId=requester_id)

# Format the INBOUND route (if available)
if hasattr(route, 'route_back') and route.route_back:
hops_back = []
for node_id_int in route.route_back:
node_id_str = f"!{node_id_int:08x}"
node = self.node_db.get_by_id(node_id_str)
if node:
hops_back.append(f"{node.short_name}")
else:
hops_back.append(f"{node_id_str}")
back_str = " -> ".join(hops_back)

response_in = f"Trace FROM {target_id} ({len(hops_back)} hops):\n{back_str}"
logging.info(f"Sending traceroute IN result to {requester_id}: {response_in}")
# Small delay to ensure order
time.sleep(1)
self.interface.sendText(response_in, destinationId=requester_id)

def on_receive(self, packet: MeshPacket, interface):
if packet.get('fromId') == '!69828b98':
logging.info(f"DEBUG: Received ANY packet from mte4: {packet}")

# dump the packet to disk (if enabled)
dump_packet(packet)

Expand Down
2 changes: 1 addition & 1 deletion src/commands/hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def handle_packet(self, packet: MeshPacket) -> None:
sender = self.bot.node_db.get_by_id(sender_id)
sender_name = sender.long_name if sender else sender_id

response = f"Hello, {sender_name}! How can I help you? (tip: try !help). I'm a bot maintained by PDY4 / pskillen@gmail.com"
response = f"Hello, {sender_name}! (tip: try !help). I'm a bot maintained by MTEK original PDY4 / https://github.com/pskillen/meshtastic-bot"
self.reply_to(sender_id, response)

def get_command_for_logging(self, message: str) -> (str, list[str] | None, str | None):
Expand Down
11 changes: 8 additions & 3 deletions src/commands/ping.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from meshtastic.protobuf.mesh_pb2 import MeshPacket

from src.commands.command import AbstractCommand
Expand All @@ -9,9 +10,12 @@ def __init__(self, bot):

def handle_packet(self, packet: MeshPacket) -> None:
message = packet['decoded']['text']
hops_away = packet['hopStart'] - packet['hopLimit']

hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
hops_away = hop_start - hop_limit

self.react_in_dm(packet, "🏓")
# self.react_in_dm(packet, "🏓")

# trim off the '!ping' command from the message
additional = message[5:].strip()
Expand All @@ -21,7 +25,8 @@ def handle_packet(self, packet: MeshPacket) -> None:
response = f"!pong: {additional}"

response += f" (ping took {hops_away} hops)"

self.reply_in_dm(packet, response)

def get_command_for_logging(self, message: str) -> (str, list[str] | None, str | None):
return self._gcfl_base_command_and_args(message)
return self._gcfl_base_command_and_args(message)
41 changes: 41 additions & 0 deletions src/commands/tr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import logging
from meshtastic.protobuf.mesh_pb2 import MeshPacket

from src.commands.command import AbstractCommand


class TracerouteCommand(AbstractCommand):
def __init__(self, bot):
super().__init__(bot, 'tr')

def handle_packet(self, packet: MeshPacket) -> None:
hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
hops_away = hop_start - hop_limit

snr = packet.get('rxSnr', 0.0)

sender_id = packet['fromId']
sender = self.bot.node_db.get_by_id(sender_id)
sender_name = sender.long_name if sender else sender_id

if hops_away == 0:
response = f"{sender_name} you are Zero Hops from me. No traceroute required!"
self.reply_in_dm(packet, response)
return

response = f"{sender_name} you are {hops_away} hops away (Signal: {snr} dB). Starting full traceroute..."
self.reply_in_dm(packet, response)

# Initiate actual traceroute
self.bot.pending_traces[sender_id] = sender_id
try:
logging.info(f"Initiating traceroute to {sender_id}")
# hopLimit=7 is standard max
self.bot.interface.sendTraceRoute(sender_id, hopLimit=7)
except Exception as e:
logging.error(f"Failed to send traceroute to {sender_id}: {e}")
self.reply_in_dm(packet, f"Error starting traceroute: {e}")

def get_command_for_logging(self, message: str) -> (str, list[str] | None, str | None):
return self._gcfl_just_base_command(message)
9 changes: 9 additions & 0 deletions src/tcp_interface.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import sys
from pubsub import pub
import time
from queue import Queue
from typing import Optional, Callable, Union
Expand Down Expand Up @@ -61,6 +62,13 @@ def __init__(self, *args,
# Store packets in a queue and resend them after reconnecting
# This will involve exposing the queue, and reloading the queue in bot.py since we create a new interface object

def onResponseTraceRoute(self, packet, routeDiscovery):
"""
Callback for when a traceroute response is received.
"""
super().onResponseTraceRoute(packet, routeDiscovery)
pub.sendMessage("meshtastic.traceroute", packet=packet, route=routeDiscovery)

def sendHeartbeat(self):
try:
super().sendHeartbeat()
Expand All @@ -79,6 +87,7 @@ def _sendPacket(
pkiEncrypted: Optional[bool] = False,
publicKey: Optional[bytes] = None,
):
logging.info(f"DEBUG: Sending packet to {destinationId} (Payload: {meshPacket.decoded.payload})")
try:
super()._sendPacket(
meshPacket=meshPacket,
Expand Down
Loading