This repository has been archived on 2026-08-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
donglora/examples/meshcore/ai_bot.py
T

1425 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Orac: a terse AI assistant on the MeshCore LoRa mesh network.
Monitors all decryptable MeshCore channels for !askai, !ai, !claude, or !orac
triggers. Also accepts direct messages (DMs) — no trigger needed for DMs.
Advertises itself on the mesh and persists state across restarts.
"""
import collections
import csv
import hashlib
import hmac
import json
import os
import random
import struct
import sys
import time
import anthropic
import serial
from cobs import cobs
from Crypto.Cipher import AES
from nacl.bindings import crypto_scalarmult
from nacl.signing import SigningKey, VerifyKey
from pathlib import Path
import donglora as dl
# ── Constants ─────────────────────────────────────────────────────
BOT_NAME = "Orac"
MAX_GRP_TEXT = 163 # max bytes for "SenderName: message" in a GRP_TXT
MAX_RESPONSE_CHARS = MAX_GRP_TEXT - len(BOT_NAME) - 2 # subtract "Orac: "
# DM plaintext: timestamp(4) + txt_type_attempt(1) + text + padding to 16
# Max payload 184 - dst(1) - src(1) - mac(2) = 180 bytes ciphertext
# 180 bytes ciphertext / 16 = 11 blocks → 176 bytes plaintext max
# 176 - 5 (header) = 171 bytes text, minus null terminator padding
MAX_DM_TEXT = 170
TRIGGERS = ("!askai", "!ai", "!claude", "!orac")
AT_MENTIONS = (f"@{BOT_NAME}".lower(), f"@[{BOT_NAME}]".lower()) # "@orac" and "@[Orac]"
CHANNEL_HISTORY_SIZE = 20 # messages per channel
DM_HISTORY_SIZE = 20 # messages per DM peer
RATE_LIMIT_PER_SENDER = 5 # for channel messages
RATE_LIMIT_GLOBAL = 2 # between any two responses
RATE_LIMIT_DM = 2 # for DMs (more interactive)
ADVERT_INTERVAL = 7200 # 2 hours in seconds
DATA_DIR = Path.home() / ".donglora"
KEY_FILE = DATA_DIR / "orac_key.bin"
STATE_FILE = DATA_DIR / "orac_state.json"
RADIO_CONFIG = {
"freq_hz": 910_525_000,
"bw": 6, # 62.5 kHz
"sf": 7,
"cr": 5, # CR 4/5
"sync_word": 0x3444,
"tx_power_dbm": -128, # TX_POWER_MAX
}
SYSTEM_PROMPT = f"""\
You are Orac, a terse AI assistant on a MeshCore LoRa radio mesh network.
ABSOLUTE HARD LIMIT: Your ENTIRE response must be {{max_chars}} characters or fewer.
This is a physical constraint of the radio protocol. Every character beyond this limit
is permanently lost and never transmitted. There is NO exception.
Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply.
You can see recent messages for context. When someone asks you a question, they may
be referring to the ongoing conversation — use the chat history to understand what they mean.
For example, "what do you think?" refers to whatever was just discussed. "tell me more" means
elaborate on the recent topic. Read the room.
Rules:
- One sentence or short phrase. Never more.
- Emoji OK but don't overdo it. No markdown, no bullet points, no formatting.
- Use common abbreviations freely (e.g., w/, b/c, approx, etc).
- Skip all pleasantries, greetings, and filler.
- You have a dry, sardonic wit. Be helpful but never wordy.
- If the question requires a long answer, give the most important point only.
- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take.
- Be creative and unpredictable. Surprise the reader.\
"""
# ── ANSI helpers ──────────────────────────────────────────────────
RST = "\033[0m"
DIM = "\033[2m"
RED = "\033[31m"
GRN = "\033[32m"
YEL = "\033[33m"
CYN = "\033[36m"
BCYN = "\033[1;36m"
BGRN = "\033[1;32m"
BMAG = "\033[1;35m"
BWHT = "\033[1;37m"
# ── Persistent state ─────────────────────────────────────────────
_state: dict = {
"channel_history": {}, # channel_name → list of messages
"dm_history": {}, # peer_pubkey_hex → list of messages
"known_nodes": {}, # pubkey_hex → {"name": str, "seen": float}
}
def _load_state():
global _state
if STATE_FILE.is_file():
try:
with open(STATE_FILE) as f:
loaded = json.load(f)
# Merge with defaults
for key in _state:
if key in loaded:
_state[key] = loaded[key]
print(f" {DIM}Loaded state from {STATE_FILE}{RST}")
except Exception as e:
print(f" {YEL}Failed to load state: {e}{RST}")
def _save_state():
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(_state, f, indent=2)
tmp.rename(STATE_FILE)
except Exception as e:
print(f" {RED}Failed to save state: {e}{RST}")
# ── Identity / Keypair ───────────────────────────────────────────
_signing_key: SigningKey | None = None
_verify_key: VerifyKey | None = None
_pubkey_bytes: bytes = b""
def _init_identity():
"""Load or generate Ed25519 keypair. Persisted to disk."""
global _signing_key, _verify_key, _pubkey_bytes
DATA_DIR.mkdir(parents=True, exist_ok=True)
if KEY_FILE.is_file():
seed = KEY_FILE.read_bytes()
if len(seed) == 32:
_signing_key = SigningKey(seed)
print(f" {DIM}Loaded keypair from {KEY_FILE}{RST}")
else:
print(f" {YEL}Invalid key file, regenerating{RST}")
_signing_key = SigningKey.generate()
KEY_FILE.write_bytes(bytes(_signing_key))
else:
_signing_key = SigningKey.generate()
KEY_FILE.write_bytes(bytes(_signing_key))
print(f" {BWHT}Generated new keypair → {KEY_FILE}{RST}")
_verify_key = _signing_key.verify_key
_pubkey_bytes = bytes(_verify_key)
print(f" {DIM}Pubkey: {_pubkey_bytes.hex()}{RST}")
print(f" {DIM}Node hash: 0x{_pubkey_bytes[0]:02x}{RST}")
def _my_hash() -> int:
"""Our 1-byte node hash (first byte of pubkey)."""
return _pubkey_bytes[0]
# ── MeshCore channel crypto ──────────────────────────────────────
def _channel_secret_from_hashtag(name: str) -> bytes:
h = hashlib.sha256(name.encode()).digest()
return h[:16] + b"\x00" * 16
def _channel_hash(secret: bytes) -> int:
key_len = 16 if secret[16:] == b"\x00" * 16 else 32
return hashlib.sha256(secret[:key_len]).digest()[0]
def _grp_verify_and_decrypt(
secret: bytes, mac_bytes: bytes, ciphertext: bytes
) -> bytes | None:
key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14)
computed = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
if computed != mac_bytes:
return None
cipher = AES.new(secret[:16], AES.MODE_ECB)
plaintext = b""
for i in range(0, len(ciphertext), 16):
plaintext += cipher.decrypt(ciphertext[i : i + 16])
return plaintext
def _parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None:
if len(plaintext) < 5:
return None
timestamp = struct.unpack_from("<I", plaintext, 0)[0]
text = plaintext[5:].split(b"\x00", 1)[0]
return timestamp, text.decode("utf-8", errors="replace")
# ── Peer-to-peer (DM) crypto ────────────────────────────────────
def _ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes:
"""Compute X25519 shared secret from our Ed25519 key and peer's Ed25519 pubkey."""
my_x25519 = bytes(_signing_key.to_curve25519_private_key())
peer_verify = VerifyKey(peer_ed25519_pub)
peer_x25519 = bytes(peer_verify.to_curve25519_public_key())
return crypto_scalarmult(my_x25519, peer_x25519)
def _peer_encrypt(shared_secret: bytes, text: str) -> bytes:
"""Encrypt a DM plaintext. Returns mac(2) + ciphertext."""
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
plaintext += text.encode("utf-8") + b"\x00"
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
return mac + ciphertext
def _peer_verify_and_decrypt(shared_secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None:
"""Verify MAC then decrypt a peer message. Returns plaintext or None."""
computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
if computed != mac_bytes:
return None
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
plaintext = b""
for i in range(0, len(ciphertext), 16):
plaintext += cipher.decrypt(ciphertext[i : i + 16])
return plaintext
def _parse_peer_plaintext(plaintext: bytes) -> str | None:
"""Parse decrypted peer message: timestamp(4) + txt_type_attempt(1) + text."""
if len(plaintext) < 5:
return None
text = plaintext[5:].split(b"\x00", 1)[0]
return text.decode("utf-8", errors="replace") if text else None
# ── Node registry ────────────────────────────────────────────────
# pubkey_hex → {"name": str, "seen": float}
# Stored in _state["known_nodes"], persisted to disk
ADVERT_MAX_AGE = 43200 # 12 hours — used for display, NOT for key expiry
# Pending DMs from unknown senders — queued until we learn their pubkey via ADVERT
# src_hash → list of (raw_payload, monotonic_timestamp)
_pending_dms: dict[int, list[tuple[bytes, float]]] = {}
PENDING_DM_TTL = 300 # 5 minutes — discard stale pending DMs
# ── Route table ─────────────────────────────────────────────────
# Learned return paths from incoming packets.
# src_hash (int) → (reversed_path_hops: list[bytes], hash_size: int)
_route_table: dict[int, tuple[list[bytes], int]] = {}
def _learn_route(src_hash: int, path_hops: list[bytes], hash_size: int):
"""Learn a return route from an incoming packet's path.
path_hops is ordered closest-relay-first as received.
We reverse it so our outgoing path goes through the same relays
back toward the sender. Always updates the route, but prefers
larger hash sizes (3 > 2 > 1) for better addressing precision.
"""
existing = _route_table.get(src_hash)
if existing is not None:
_, existing_hs = existing
if hash_size < existing_hs:
return # Don't downgrade hash size
_route_table[src_hash] = (list(reversed(path_hops)), hash_size)
def _get_route(dest_hash: int) -> tuple[list[bytes], int] | None:
"""Get a learned return route for a destination, or None if unknown."""
return _route_table.get(dest_hash)
_RT_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
def _route_name(route_type: int) -> str:
return _RT_NAMES.get(route_type, f"rt{route_type}")
def _register_node(pubkey: bytes, name: str) -> bool:
"""Register a node. Returns True if this is a NEW node (not just an update)."""
pk_hex = pubkey.hex()
is_new = pk_hex not in _state["known_nodes"]
_state["known_nodes"][pk_hex] = {"name": name, "seen": time.time()}
_save_state()
return is_new
def _lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]:
"""Find all known nodes whose pubkey first byte matches. Returns [(pubkey, name)].
Never expires keys — once we learn a peer's pubkey, we can always decrypt
their DMs. Peers shouldn't need to re-advertise just to message the bot.
"""
results = []
for pk_hex, info in _state["known_nodes"].items():
pk_bytes = bytes.fromhex(pk_hex)
if pk_bytes[0] == hash_byte:
results.append((pk_bytes, info["name"]))
return results
def _node_name(pubkey_hex: str) -> str:
info = _state["known_nodes"].get(pubkey_hex)
return info["name"] if info else pubkey_hex[:8]
# ── Channel registry ─────────────────────────────────────────────
_KNOWN_CHANNELS: dict[str, bytes] = {}
_CHANNEL_BY_HASH: dict[int, list[tuple[str, bytes]]] = {}
def _register_channel(name: str, secret: bytes):
_KNOWN_CHANNELS[name] = secret
h = _channel_hash(secret)
_CHANNEL_BY_HASH.setdefault(h, []).append((name, secret))
_CHANNELS_CSV = Path(__file__).parent / "channels.csv"
def _init_channels():
if _CHANNELS_CSV.is_file():
with open(_CHANNELS_CSV, newline="") as f:
for row in csv.DictReader(f):
name = row["channel_name"]
is_hashtag = row["hashtag"].strip().lower() == "true"
key_hex = row["key_hex"].strip()
if is_hashtag:
secret = _channel_secret_from_hashtag(name)
else:
secret = bytes.fromhex(key_hex) + b"\x00" * 16
_register_channel(name, secret)
else:
print(f" {RED}channels.csv not found at {_CHANNELS_CSV}{RST}")
for name in ("#devtest", "#devtestdevtest", "#askai", "#orac", "#ai", "#aibot", "#aibots"):
_register_channel(name, _channel_secret_from_hashtag(name))
_init_channels()
# ── GRP_TXT encrypt + transmit ───────────────────────────────────
def _grp_encrypt(secret: bytes, sender: str, text: str) -> bytes:
ch = _channel_hash(secret)
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
plaintext += f"{sender}: {text}\x00".encode("utf-8")[:MAX_GRP_TEXT]
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
cipher = AES.new(secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14)
mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
return bytes([ch]) + mac + ciphertext
def _grp_build_packet(channel_payload: bytes) -> bytes:
header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + channel_payload
def _transmit_packet(ser: serial.Serial, packet: bytes, label: str = "Transmit"):
"""Transmit a raw MeshCore packet."""
try:
resp = send_cmd(ser, {"type": "Transmit", "payload": packet}, label=label)
if resp is None:
print(f" {RED}TX failed: no response from radio{RST}")
elif resp["type"] == "Error":
print(f" {RED}TX failed: {resp}{RST}")
except Exception as e:
print(f" {RED}TX exception: {e}{RST}")
def _grp_transmit(ser: serial.Serial, channel_name: str, sender: str, text: str):
secret = _KNOWN_CHANNELS.get(channel_name)
if secret is None:
print(f" {RED}TX failed: unknown channel {channel_name}{RST}")
return
payload = _grp_encrypt(secret, sender, text)
packet = _grp_build_packet(payload)
_transmit_packet(ser, packet)
# ── ADVERT construction + transmit ───────────────────────────────
def _build_advert_packet() -> bytes:
"""Build a MeshCore ADVERT packet for this node."""
timestamp = struct.pack("<I", int(time.time()))
# App data: flags(1) + name(UTF-8, no null terminator)
# flags: 0x81 = node_type=chat(1) + has_name(bit 7)
app_data = bytes([0x81]) + BOT_NAME.encode("utf-8")
# Message to sign: pubkey + timestamp + app_data
sign_msg = _pubkey_bytes + timestamp + app_data
signed = _signing_key.sign(sign_msg)
signature = signed.signature # 64 bytes
# ADVERT payload: pubkey(32) + timestamp(4) + signature(64) + app_data
advert_payload = _pubkey_bytes + timestamp + signature + app_data
# Wrap in MeshCore packet
header = bytes([0x11]) # ADVERT flood: (4 << 2) | 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + advert_payload
def _send_advert(ser: serial.Serial):
"""Transmit our ADVERT."""
packet = _build_advert_packet()
print(f" {BCYN}Sending ADVERT as {BOT_NAME} (hash=0x{_my_hash():02x}){RST}")
_transmit_packet(ser, packet, label="ADVERT")
# ── DM transmit ──────────────────────────────────────────────────
def _build_routed_packet(payload_type: int, payload: bytes, dest_hash: int) -> bytes:
"""Build a packet using a learned route if available, otherwise flood."""
route = _get_route(dest_hash)
if route is not None:
hops, hash_size = route
hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F)
path_data = b"".join(hops)
# direct route_type = 2
header = bytes([(payload_type << 2) | 2])
return header + bytes([path_len_byte]) + path_data + payload
else:
# flood route_type = 1
header = bytes([(payload_type << 2) | 1])
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + payload
def _dm_transmit(ser: serial.Serial, peer_pubkey: bytes, text: str):
"""Encrypt and transmit a TXT_MSG DM using learned route or flood."""
# Simulate human typing delay (~40ms/char, clamped to 1–5s)
delay = min(max(len(text) * 0.04, 1.0), 5.0)
time.sleep(delay)
shared_secret = _ecdh_shared_secret(peer_pubkey)
mac_ct = _peer_encrypt(shared_secret, text)
# Payload: dest_hash(1) + src_hash(1) + mac(2) + ciphertext
dm_payload = bytes([peer_pubkey[0], _my_hash()]) + mac_ct
packet = _build_routed_packet(0x02, dm_payload, peer_pubkey[0])
peer_name = _node_name(peer_pubkey.hex())
route = _get_route(peer_pubkey[0])
route_str = "direct" if route is not None else "flood"
_transmit_packet(ser, packet, label=f"DM({route_str})\u2192{peer_name}")
# ── ACK transmit ─────────────────────────────────────────────────
def _compute_ack_hash(plaintext: bytes, sender_pubkey: bytes) -> bytes:
"""Compute 4-byte ACK hash per MeshCore BaseChatMesh.cpp.
Hash = SHA-256(frag1 || sender_pubkey)[0:4]
frag1 = timestamp(4) + txt_type_attempt(1) + text (no null terminator)
sender_pubkey = sender's Ed25519 public key (32 bytes)
"""
text_bytes = plaintext[5:]
null_pos = text_bytes.find(b"\x00")
text_len = null_pos if null_pos >= 0 else len(text_bytes)
frag1 = plaintext[: 5 + text_len]
return hashlib.sha256(frag1 + sender_pubkey).digest()[:4]
def _send_ack(ser: serial.Serial, ack_crc: bytes, dest_hash: int):
"""Send an ACK using learned route or flood, with a brief delay for the DM flood to clear."""
time.sleep(0.5)
packet = _build_routed_packet(0x03, ack_crc, dest_hash)
_transmit_packet(ser, packet, label="ACK")
def _send_path_return(ser: serial.Serial, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int):
"""Send a PATH return (0x08) so the sender can learn a direct route to us.
Per spec Section 9: inner plaintext = path_len(1) + path + extra_type(1) + extra.
The path is reversed (our incoming path becomes the sender's outgoing path).
"""
reversed_hops = list(reversed(path_hops))
hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F)
path_data = b"".join(reversed_hops)
# Inner plaintext: path_len + path + 0xFF (dummy extra) + 4 random bytes
inner = bytes([path_len_byte]) + path_data + b"\xff" + os.urandom(4)
pad_len = (16 - len(inner) % 16) % 16
inner += b"\x00" * pad_len
shared_secret = _ecdh_shared_secret(peer_pubkey)
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(inner), 16):
ciphertext += cipher.encrypt(inner[i : i + 16])
mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
# Outer: dest_hash + src_hash + mac + ciphertext
path_payload = bytes([peer_pubkey[0], _my_hash()]) + mac + ciphertext
packet = _build_routed_packet(0x08, path_payload, peer_pubkey[0])
_transmit_packet(ser, packet, label="PATH")
# ── Serial / USB ─────────────────────────────────────────────────
def open_connection(port: str | None = None):
"""Open a DongLoRa connection (mux auto-detected, falls back to USB)."""
return dl.connect(port=port, timeout=2)
# ── COBS framing ─────────────────────────────────────────────────
def cobs_frame(payload: bytes) -> bytes:
return cobs.encode(payload) + b"\x00"
def read_frame(ser: serial.Serial) -> bytes | None:
buf = b""
while True:
b = ser.read(1)
if not b:
return None
if b == b"\x00":
break
buf += b
if not buf:
return None
try:
return cobs.decode(buf)
except cobs.DecodeError:
return None
# ── Command encoding / response decoding ─────────────────────────
def encode_radio_config(cfg: dict) -> bytes:
return struct.pack(
"<IBBBHBHB",
cfg["freq_hz"],
cfg["bw"],
cfg["sf"],
cfg["cr"],
cfg["sync_word"],
cfg["tx_power_dbm"] & 0xFF,
cfg.get("preamble_len", 0),
cfg.get("cad", 1),
)
def encode_command(cmd: dict) -> bytes:
kind = cmd["type"]
tags = {
"Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3,
"StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7,
}
out = bytes([tags[kind]])
if kind == "SetConfig":
out += encode_radio_config(cmd["config"])
elif kind == "Transmit":
config = cmd.get("config")
if config is None:
out += b"\x00"
else:
out += b"\x01" + encode_radio_config(config)
payload = cmd["payload"]
out += struct.pack("<H", len(payload)) + payload
return out
def decode_response(data: bytes) -> dict:
if not data:
return {"type": "Empty"}
tag = data[0]
rest = data[1:]
if tag == 0:
return {"type": "Pong"}
elif tag == 1:
return {"type": "Config", "raw": rest.hex()}
elif tag == 2:
rssi = struct.unpack_from("<h", rest, 0)[0]
snr = struct.unpack_from("<h", rest, 2)[0]
plen = struct.unpack_from("<H", rest, 4)[0]
payload = rest[6 : 6 + plen]
return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload}
elif tag == 3:
return {"type": "TxDone"}
elif tag == 4:
return {"type": "Ok"}
elif tag == 5:
code = rest[0] if rest else -1
return {"type": "Error", "code": code}
else:
return {"type": f"Unknown({tag})", "raw": rest.hex()}
def send_cmd(
ser: serial.Serial, cmd: dict, label: str = "", quiet: bool = False
) -> dict | None:
payload = encode_command(cmd)
frame = cobs_frame(payload)
if not quiet:
print(f" {DIM}>>>{RST} {label}")
ser.write(frame)
ser.flush()
for _ in range(50):
resp_data = read_frame(ser)
if resp_data is None:
if not quiet:
print(f" {DIM}<<<{RST} {YEL}timeout{RST}")
return None
resp = decode_response(resp_data)
if resp["type"] == "RxPacket":
continue
if not quiet:
t = resp["type"]
print(f" {DIM}<<<{RST} {t}")
return resp
if not quiet:
print(f" {DIM}<<<{RST} {YEL}timeout (RxPacket flood){RST}")
return None
# ── Claude API ───────────────────────────────────────────────────
_client: anthropic.Anthropic | None = None
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
_client = anthropic.Anthropic()
return _client
def _shorten_with_claude(text: str, max_chars: int) -> str:
try:
resp = _get_client().messages.create(
model="claude-sonnet-4-6",
max_tokens=256,
messages=[
{
"role": "user",
"content": f"Shorten this to {max_chars} characters: {text}",
}
],
)
shortened = resp.content[0].text.strip()
except Exception as e:
print(f" {RED}Shorten API error: {e}{RST}")
shortened = text
encoded = shortened.encode("utf-8")
if len(encoded) > max_chars:
while len(shortened.encode("utf-8")) > max_chars - 1:
shortened = shortened[:-1]
shortened += "…"
return shortened
def _extract_text(resp) -> str:
text = ""
for block in resp.content:
if block.type == "text" and block.text.strip():
text = block.text.strip()
return text
def call_claude(query: str, sender: str, history: list[str], max_chars: int, context_label: str) -> str | None:
"""Send a query to Claude with conversation context. Returns response text or None."""
try:
nonce = random.randint(1000, 9999)
now = time.strftime("%Y-%m-%d %H:%M %Z")
prompt = SYSTEM_PROMPT.replace("{max_chars}", str(max_chars)) + f"\n\nCurrent date/time: {now}"
if history:
context = "\n".join(history)
user_content = (
f"[seed:{nonce}]\n"
f"Recent messages in {context_label}:\n{context}\n\n"
f"{sender} says: {query}"
)
else:
user_content = f"[seed:{nonce}] {sender} says: {query}"
messages = [{"role": "user", "content": user_content}]
tools = [{"type": "web_search_20260209", "name": "web_search"}]
text = ""
for round_num in range(3):
resp = _get_client().messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
temperature=1.0,
system=prompt,
tools=tools,
messages=messages,
)
text = _extract_text(resp)
if text:
break
if resp.stop_reason != "tool_use":
block_types = [b.type for b in resp.content]
print(f" {YEL}No text, stop={resp.stop_reason}, blocks={block_types}{RST}")
break
print(f" {DIM}(web search round {round_num + 1})...{RST}")
messages.append({"role": "assistant", "content": resp.content})
tool_results = []
for block in resp.content:
if hasattr(block, "id") and block.type == "server_tool_use":
tool_results.append(
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
)
if tool_results:
messages.append({"role": "user", "content": tool_results})
else:
break
if not text:
print(f" {YEL}No text after tool loop{RST}")
return None
except Exception as e:
print(f" {RED}Claude API error: {e}{RST}")
return None
if len(text.encode("utf-8")) > max_chars:
print(f" {YEL}Response too long ({len(text.encode('utf-8'))}B), shortening...{RST}")
text = _shorten_with_claude(text, max_chars)
return text
def _rate_limit_message() -> str:
try:
nonce = random.randint(1000, 9999)
resp = _get_client().messages.create(
model="claude-sonnet-4-6",
max_tokens=100,
temperature=1.0,
system=(
f"You are Orac, a sardonic AI on a radio mesh network. "
f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) "
f"telling someone to slow down and try again in a few seconds. "
f"Be witty, varied, and in-character. No quotes around the message."
),
messages=[
{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}
],
)
text = resp.content[0].text.strip().strip('"\'')
if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text:
return text
except Exception:
pass
return "Patience. Try again in a moment."
# ── Trigger detection ────────────────────────────────────────────
def _extract_trigger_query(text: str) -> str | None:
"""Check for !triggers and @mentions. Returns the query (everything except the trigger) or None."""
lower = text.lower()
for trigger in (*TRIGGERS, *AT_MENTIONS):
idx = lower.find(trigger)
if idx != -1:
before = text[:idx]
after = text[idx + len(trigger) :]
query = (before + " " + after).strip()
if query:
return query
return None
# ── Packet parsing helpers ───────────────────────────────────────
def _parse_header_and_path(packet: bytes) -> tuple[int, int, int, int, bytes, list[bytes], int] | None:
"""Parse MeshCore header + path.
Returns (payload_type, route_type, payload_ver, pos, payload, path_hops, hash_size) or None.
path_hops is a list of raw hash bytes for each hop (closest relay first).
"""
if len(packet) < 3:
return None
header = packet[0]
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
payload_ver = (header >> 6) & 0x03
if payload_ver != 0:
return None
has_tc = route_type in (0, 3)
for skip_tc in ([True, False] if has_tc else [False]):
pos = 5 if skip_tc else 1
if pos >= len(packet):
continue
path_len_byte = packet[pos]
pos += 1
hash_size_code = path_len_byte >> 6
if hash_size_code == 3:
continue
hash_size = hash_size_code + 1
hop_count = path_len_byte & 0x3F
path_bytes = hop_count * hash_size
if path_bytes > 64 or pos + path_bytes > len(packet):
continue
hops = []
for i in range(hop_count):
hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size])
pos += path_bytes
payload = packet[pos:]
if payload and len(payload) <= 184:
return payload_type, route_type, payload_ver, pos, payload, hops, hash_size
return None
# ── GRP_TXT interception ────────────────────────────────────────
def _try_decrypt_grp(raw_payload: bytes) -> tuple[str, str] | None:
if len(raw_payload) < 19:
return None
ch = raw_payload[0]
mac_bytes = raw_payload[1:3]
ciphertext = raw_payload[3:]
candidates = _CHANNEL_BY_HASH.get(ch, [])
for chan_name, secret in candidates:
plaintext = _grp_verify_and_decrypt(secret, mac_bytes, ciphertext)
if plaintext is not None:
parsed = _parse_grp_plaintext(plaintext)
if parsed:
_, text = parsed
return chan_name, text
return None
# ── DM interception ──────────────────────────────────────────────
def _try_decrypt_dm(raw_payload: bytes) -> tuple[bytes, str, str, bytes] | None:
"""Try to decrypt a TXT_MSG DM addressed to us.
Returns (sender_pubkey, sender_name, message_text, raw_plaintext) or None.
raw_plaintext is needed for ACK hash computation.
"""
if len(raw_payload) < 20:
return None
dest_hash = raw_payload[0]
src_hash = raw_payload[1]
mac_bytes = raw_payload[2:4]
ciphertext = raw_payload[4:]
print(f" {DIM}TXT_MSG dest=0x{dest_hash:02x} src=0x{src_hash:02x} me=0x{_my_hash():02x} [{len(ciphertext)}B]{RST}")
if dest_hash != _my_hash():
return None
candidates = _lookup_node_by_hash(src_hash)
if not candidates:
print(f" {YEL}DM for us but no known node with hash 0x{src_hash:02x}{RST}")
return None
for peer_pubkey, peer_name in candidates:
try:
shared_secret = _ecdh_shared_secret(peer_pubkey)
plaintext = _peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is not None:
text = _parse_peer_plaintext(plaintext)
if text:
return peer_pubkey, peer_name, text, plaintext
except Exception:
# ECDH failed — pubkey is likely corrupted (bad ADVERT). Evict it.
pk_hex = peer_pubkey.hex()
print(f" {YEL}Evicting bad key for {peer_name} ({pk_hex[:16]}...){RST}")
_state["known_nodes"].pop(pk_hex, None)
_save_state()
continue
return None
RESP_SERVER_LOGIN_OK = 0x01
def _try_decrypt_anon_req(raw_payload: bytes) -> tuple[bytes, str, bytes | None] | None:
"""Try to decrypt an ANON_REQ (login). Returns (sender_pubkey, sender_name, plaintext_or_None) or None."""
if len(raw_payload) < 51:
return None
dest_hash = raw_payload[0]
sender_pubkey = raw_payload[1:33]
mac_bytes = raw_payload[33:35]
ciphertext = raw_payload[35:]
print(f" {BCYN}ANON_REQ{RST} dest=0x{dest_hash:02x} sender={sender_pubkey.hex()[:16]}... [{len(ciphertext)}B]")
if dest_hash != _my_hash():
return None
# Register sender — we now have their full pubkey
peer_name = _node_name(sender_pubkey.hex())
is_new = _register_node(sender_pubkey, peer_name)
if is_new:
print(f" {BCYN}Registered new peer from ANON_REQ: {peer_name} (hash=0x{sender_pubkey[0]:02x}){RST}")
try:
shared_secret = _ecdh_shared_secret(sender_pubkey)
plaintext = _peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is not None:
return sender_pubkey, peer_name, plaintext
else:
print(f" {YEL}ANON_REQ MAC mismatch{RST}")
except Exception as e:
print(f" {RED}ANON_REQ decrypt error: {e}{RST}")
# Even if decryption failed, we registered the pubkey — return with no plaintext
return sender_pubkey, peer_name, None
def _send_login_response(ser: serial.Serial, peer_pubkey: bytes):
"""Send a RESPONSE packet (login OK) back to the peer, like a room server."""
shared_secret = _ecdh_shared_secret(peer_pubkey)
# Response payload: timestamp(4) + RESP_SERVER_LOGIN_OK(1) + zero(1) + permissions(2)
reply = struct.pack("<I", int(time.time()))
reply += bytes([RESP_SERVER_LOGIN_OK, 0x00, 0x00, 0xFF]) # OK, no flags, no admin, full perms
# Encrypt
mac_ct = _peer_encrypt(shared_secret, "") # we'll build raw plaintext instead
# Actually, build the raw plaintext manually (not a text message)
plaintext = reply
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
# RESPONSE packet: dest_hash(1) + src_hash(1) + mac(2) + ciphertext
resp_payload = bytes([peer_pubkey[0], _my_hash()]) + mac + ciphertext
packet = _build_routed_packet(0x01, resp_payload, peer_pubkey[0])
_transmit_packet(ser, packet, label=f"LOGIN_RESP→{_node_name(peer_pubkey.hex())}")
# ── ADVERT decoding ──────────────────────────────────────────────
def _try_decode_advert(raw_payload: bytes) -> tuple[bytes, str] | None:
"""Decode and verify an ADVERT payload. Returns (pubkey, name) or None."""
if len(raw_payload) < 100:
return None
pubkey = raw_payload[0:32]
timestamp = raw_payload[32:36]
signature = raw_payload[36:100]
app_data = raw_payload[100:]
# Verify Ed25519 signature to reject corrupted ADVERTs
try:
verify_key = VerifyKey(pubkey)
sign_msg = pubkey + timestamp + app_data
verify_key.verify(sign_msg, signature)
except Exception:
return None
name = ""
if app_data:
flags = app_data[0]
pos = 1
if flags & 0x10: # has location
pos += 8
if flags & 0x20: # feat1
pos += 2
if flags & 0x40: # feat2
pos += 2
if flags & 0x80 and pos < len(app_data): # has name
name = app_data[pos:].decode("utf-8", errors="replace")
return pubkey, name if name else pubkey.hex()[:8]
# ── Deduplication ────────────────────────────────────────────────
DEDUP_TTL = 120
_seen_packets: dict[bytes, float] = {}
# DM dedup on decrypted text (catches retries with different attempt counters)
DM_DEDUP_TTL = 60
_seen_dm_texts: dict[str, float] = {} # "pubkey_hex:text" → monotonic timestamp
def _compute_packet_hash(payload_type: int, payload: bytes) -> bytes:
"""Compute 8-byte packet hash per spec Section 16."""
return hashlib.sha256(bytes([payload_type]) + payload).digest()[:8]
def _is_duplicate(payload_type: int, payload: bytes) -> bool:
"""Dedup via 8-byte packet hash (spec Section 16)."""
now = time.monotonic()
if len(_seen_packets) > 500:
expired = [k for k, t in _seen_packets.items() if now - t > DEDUP_TTL]
for k in expired:
del _seen_packets[k]
pkt_hash = _compute_packet_hash(payload_type, payload)
if pkt_hash in _seen_packets and now - _seen_packets[pkt_hash] < DEDUP_TTL:
return True
_seen_packets[pkt_hash] = now
return False
def _is_dm_duplicate(peer_pubkey_hex: str, text: str) -> bool:
"""Dedup on decrypted DM text (catches retries with different attempt counters)."""
now = time.monotonic()
if len(_seen_dm_texts) > 200:
expired = [k for k, t in _seen_dm_texts.items() if now - t > DM_DEDUP_TTL]
for k in expired:
del _seen_dm_texts[k]
key = f"{peer_pubkey_hex}:{text}"
if key in _seen_dm_texts and now - _seen_dm_texts[key] < DM_DEDUP_TTL:
return True
_seen_dm_texts[key] = now
return False
# ── History (persistent) ────────────────────────────────────────
def _record_channel_msg(channel: str, text: str):
hist = _state["channel_history"]
if channel not in hist:
hist[channel] = []
hist[channel].append(text)
# Trim to max size
if len(hist[channel]) > CHANNEL_HISTORY_SIZE:
hist[channel] = hist[channel][-CHANNEL_HISTORY_SIZE:]
_save_state()
def _get_channel_history(channel: str) -> list[str]:
return list(_state["channel_history"].get(channel, []))
def _record_dm_msg(peer_pubkey_hex: str, text: str):
hist = _state["dm_history"]
if peer_pubkey_hex not in hist:
hist[peer_pubkey_hex] = []
hist[peer_pubkey_hex].append(text)
if len(hist[peer_pubkey_hex]) > DM_HISTORY_SIZE:
hist[peer_pubkey_hex] = hist[peer_pubkey_hex][-DM_HISTORY_SIZE:]
_save_state()
def _get_dm_history(peer_pubkey_hex: str) -> list[str]:
return list(_state["dm_history"].get(peer_pubkey_hex, []))
# ── Rate limiting ────────────────────────────────────────────────
_last_response: dict[str, float] = {}
_last_global_response = 0.0
_last_rate_limit_reply: dict[str, float] = {}
RATE_LIMIT_REPLY_COOLDOWN = 30
def _rate_limit_check(key: str) -> bool:
global _last_global_response
now = time.monotonic()
if now - _last_global_response < RATE_LIMIT_GLOBAL:
return False
limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER
if key in _last_response and now - _last_response[key] < limit:
return False
return True
def _rate_limit_reply_ok(key: str) -> bool:
now = time.monotonic()
if key in _last_rate_limit_reply and now - _last_rate_limit_reply[key] < RATE_LIMIT_REPLY_COOLDOWN:
return False
_last_rate_limit_reply[key] = now
return True
def _rate_limit_record(key: str):
global _last_global_response
now = time.monotonic()
_last_response[key] = now
_last_global_response = now
# ── Bot loop ─────────────────────────────────────────────────────
def bot_loop(ser: serial.Serial):
send_cmd(ser, {"type": "Ping"}, "Ping")
send_cmd(
ser,
{"type": "SetConfig", "config": RADIO_CONFIG},
"SetConfig 910.525/62.5k/SF7/CR4/5",
)
send_cmd(ser, {"type": "StartRx"}, "StartRx")
# Send initial ADVERT
_send_advert(ser)
last_advert = time.monotonic()
print(f"\n {BWHT}{BOT_NAME} listening{RST} {DIM}(Ctrl+C to stop){RST}")
triggers_str = ", ".join(TRIGGERS)
print(f" {DIM}Channels: {len(_KNOWN_CHANNELS)} | Triggers: {triggers_str} | DMs: enabled{RST}\n")
ser.timeout = 1
while True:
# Periodic ADVERT
now = time.monotonic()
if now - last_advert >= ADVERT_INTERVAL:
_send_advert(ser)
last_advert = now
data = read_frame(ser)
if data is None:
continue
try:
resp = decode_response(data)
except Exception:
continue
if resp["type"] != "RxPacket":
continue
packet = resp["payload"]
parsed = _parse_header_and_path(packet)
if parsed is None:
continue
payload_type, route_type, payload_ver, pos, raw_payload, path_hops, hash_size = parsed
# Learn return route from incoming packet's path
if raw_payload and len(raw_payload) >= 2:
if payload_type in (0x01, 0x02, 0x08): # RESPONSE, TXT_MSG, PATH
src_hash = raw_payload[1]
elif payload_type == 0x04: # ADVERT
src_hash = raw_payload[0]
else:
src_hash = None
if src_hash is not None:
_learn_route(src_hash, path_hops, hash_size)
# Dedup via packet hash (spec Section 16)
if _is_duplicate(payload_type, raw_payload):
continue
# ── ACK (0x03) — ignore (we fire-and-forget DMs) ──
if payload_type == 0x03:
continue
# ── ADVERT (0x04) ──
if payload_type == 0x04:
result = _try_decode_advert(raw_payload)
if result:
pubkey, name = result
if pubkey == _pubkey_bytes:
continue # ignore our own ADVERT echo
is_new = _register_node(pubkey, name)
print(f" {BCYN}ADVERT{RST} {name} {DIM}(hash=0x{pubkey[0]:02x} pk={pubkey.hex()[:16]}...){RST}")
# Check for pending DMs from this node's hash
node_hash = pubkey[0]
if node_hash in _pending_dms and _pending_dms[node_hash]:
now = time.monotonic()
pending = _pending_dms.pop(node_hash)
print(f" {BCYN}Processing {len(pending)} pending DM(s) from {name}{RST}")
for pending_payload, ts in pending:
if now - ts > PENDING_DM_TTL:
continue # expired
dm_result = _try_decrypt_dm(pending_payload)
if dm_result:
peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result
pk_hex = peer_pubkey.hex()
_send_ack(ser, _compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0])
if _is_dm_duplicate(pk_hex, dm_text):
continue
print(f" {BCYN}DM(flood){RST} from {BWHT}{peer_name}{RST}: {dm_text}")
_record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
history = _get_dm_history(pk_hex)
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
if response:
_rt = "direct" if _get_route(peer_pubkey[0]) else "flood"
print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {peer_name}: {response}{RST}")
_dm_transmit(ser, peer_pubkey, response)
_record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(f"dm:{pk_hex}")
continue
# ── GRP_TXT (0x05) ──
if payload_type == 0x05:
result = _try_decrypt_grp(raw_payload)
if result is None:
continue
channel_name, text = result
print(f" {BMAG}GRP_TXT{RST} {BGRN}{channel_name}{RST} {BWHT}{text}{RST}")
_record_channel_msg(channel_name, text)
if ": " not in text:
continue
sender, _, body = text.partition(": ")
if sender == BOT_NAME:
continue
query = _extract_trigger_query(body)
if query is None:
continue
print(f" {BMAG}>>> Query from {sender} on {channel_name}: {query}{RST}")
rl_key = f"ch:{channel_name}:{sender}"
if not _rate_limit_check(rl_key):
print(f" {YEL}Rate limited: {sender} on {channel_name}{RST}")
if _rate_limit_reply_ok(f"ch:{channel_name}"):
msg = _rate_limit_message()
print(f" {BGRN}<<< {BOT_NAME}: {msg}{RST}")
_grp_transmit(ser, channel_name, BOT_NAME, msg)
_rate_limit_record(rl_key)
continue
history = _get_channel_history(channel_name)
response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}")
if response is None:
continue
print(f" {BGRN}<<< {BOT_NAME}: {response}{RST}")
_grp_transmit(ser, channel_name, BOT_NAME, response)
_record_channel_msg(channel_name, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
continue
# ── ANON_REQ (0x07) — LOGIN from companion (includes full pubkey) ──
if payload_type == 0x07:
result = _try_decrypt_anon_req(raw_payload)
if result is None:
continue
peer_pubkey, peer_name, plaintext = result
# Learn route before responding so login response can be direct-routed
_learn_route(peer_pubkey[0], path_hops, hash_size)
# Send login-OK response so the companion knows we accepted them
print(f" {BCYN}LOGIN{RST} from {BWHT}{peer_name}{RST} — sending login OK")
_send_login_response(ser, peer_pubkey)
# If we also got a decrypted message, process pending DMs from this peer
pk_hex = peer_pubkey.hex()
node_hash = peer_pubkey[0]
if node_hash in _pending_dms and _pending_dms[node_hash]:
now_mono = time.monotonic()
pending = _pending_dms.pop(node_hash)
print(f" {BCYN}Processing {len(pending)} pending DM(s) from {peer_name}{RST}")
for pending_payload, ts in pending:
if now_mono - ts > PENDING_DM_TTL:
continue
dm_result = _try_decrypt_dm(pending_payload)
if dm_result:
dm_pubkey, dm_name, dm_text, dm_plaintext = dm_result
# ACK + dedup
_send_ack(ser, _compute_ack_hash(dm_plaintext, dm_pubkey), dm_pubkey[0])
if _is_dm_duplicate(pk_hex, dm_text):
continue
print(f" {BCYN}DM(flood){RST} from {BWHT}{dm_name}{RST}: {dm_text}")
_record_dm_msg(pk_hex, f"{dm_name}: {dm_text}")
history = _get_dm_history(pk_hex)
response = call_claude(dm_text, dm_name, history, MAX_DM_TEXT, f"DM with {dm_name}")
if response:
_rt = "direct" if _get_route(dm_pubkey[0]) else "flood"
print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {dm_name}: {response}{RST}")
_dm_transmit(ser, dm_pubkey, response)
_record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(f"dm:{pk_hex}")
continue
# ── TXT_MSG DM (0x02) ──
if payload_type == 0x02:
result = _try_decrypt_dm(raw_payload)
if result is None:
# If addressed to us but sender unknown, queue for later
if len(raw_payload) >= 4 and raw_payload[0] == _my_hash():
src_hash = raw_payload[1]
if not _lookup_node_by_hash(src_hash):
_pending_dms.setdefault(src_hash, []).append((raw_payload, time.monotonic()))
if len(_pending_dms[src_hash]) == 1:
print(f" {BCYN}Queued DM from unknown 0x{src_hash:02x}, waiting for their ADVERT{RST}")
continue
peer_pubkey, peer_name, dm_text, raw_plaintext = result
# Send ACK immediately so the sender stops retrying
ack_crc = _compute_ack_hash(raw_plaintext, peer_pubkey)
_send_ack(ser, ack_crc, peer_pubkey[0])
# Send PATH return so sender can learn a direct route to us
_send_path_return(ser, peer_pubkey, path_hops, hash_size)
pk_hex = peer_pubkey.hex()
# Dedup on decrypted text (catches retries with different attempt counters)
if _is_dm_duplicate(pk_hex, dm_text):
continue
print(f" {BCYN}DM({_route_name(route_type)}){RST} from {BWHT}{peer_name}{RST}: {dm_text}")
_record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
rl_key = f"dm:{pk_hex}"
if not _rate_limit_check(rl_key):
print(f" {YEL}Rate limited DM: {peer_name}{RST}")
continue
history = _get_dm_history(pk_hex)
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
if response is None:
continue
_rt = "direct" if _get_route(peer_pubkey[0]) else "flood"
print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {peer_name}: {response}{RST}")
_dm_transmit(ser, peer_pubkey, response)
_record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
continue
# ── Main ─────────────────────────────────────────────────────────
def main():
if not os.environ.get("ANTHROPIC_API_KEY"):
print(f" {RED}ANTHROPIC_API_KEY environment variable not set{RST}")
sys.exit(1)
_init_identity()
_load_state()
print(f" {BWHT}{BOT_NAME}{RST} — MeshCore AI Bot")
print(f" {DIM}Max GRP response: {MAX_RESPONSE_CHARS} chars | Max DM response: {MAX_DM_TEXT} chars{RST}")
print(f" {DIM}Channels: {len(_KNOWN_CHANNELS)} | Known nodes: {len(_state['known_nodes'])}{RST}")
port = sys.argv[1] if len(sys.argv) > 1 else None
while True:
try:
print(f" {DIM}Connecting...{RST}")
ser = open_connection(port)
print(f" {GRN}Connected{RST}")
bot_loop(ser)
except (serial.SerialException, ConnectionError, OSError) as e:
print(f"\n {RED}Disconnected: {e}{RST}")
print(f" {DIM}Reconnecting when device reappears...{RST}")
time.sleep(1)
except KeyboardInterrupt:
print()
_save_state()
try:
ser.timeout = 2
send_cmd(ser, {"type": "StopRx"}, "StopRx")
except Exception:
pass
break
if __name__ == "__main__":
main()