diff --git a/README.md b/README.md index 6bbee32..a22353e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 | --- diff --git a/src/api/StorageAPI.py b/src/api/StorageAPI.py index ff38e64..595a443 100644 --- a/src/api/StorageAPI.py +++ b/src/api/StorageAPI.py @@ -4,6 +4,7 @@ import os import traceback from datetime import datetime +from json import JSONDecodeError from pathlib import Path from typing import Union @@ -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) @@ -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]: """ diff --git a/src/bot.py b/src/bot.py index 8a017d5..05131bd 100644 --- a/src/bot.py +++ b/src/bot.py @@ -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") @@ -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: @@ -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) diff --git a/src/commands/hello.py b/src/commands/hello.py index 6f65435..737ab27 100644 --- a/src/commands/hello.py +++ b/src/commands/hello.py @@ -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): diff --git a/src/commands/ping.py b/src/commands/ping.py index 4d84317..54585a2 100644 --- a/src/commands/ping.py +++ b/src/commands/ping.py @@ -1,3 +1,4 @@ +import logging from meshtastic.protobuf.mesh_pb2 import MeshPacket from src.commands.command import AbstractCommand @@ -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() @@ -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) \ No newline at end of file diff --git a/src/commands/tr.py b/src/commands/tr.py new file mode 100644 index 0000000..b7312db --- /dev/null +++ b/src/commands/tr.py @@ -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) diff --git a/src/tcp_interface.py b/src/tcp_interface.py index 8572d56..a1ed565 100644 --- a/src/tcp_interface.py +++ b/src/tcp_interface.py @@ -1,5 +1,6 @@ import logging import sys +from pubsub import pub import time from queue import Queue from typing import Optional, Callable, Union @@ -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() @@ -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,