Files
ai-bot/orac/bot.py
T
swaitsandClaude Opus 4.6 9e6f171c5d feat!: modularize into orac/ package, add Public channel, deduplicate
Split 1424-line monolith into orac/ package with clean separation:
- constants.py — bot identity, radio config, triggers, limits
- crypto.py — Ed25519 identity, channel/peer crypto, ECDH
- meshcore.py — packet parsing/building, routing, dedup, node registry
- ai.py — Claude API integration, query extraction
- state.py — JSON persistence, history tracking
- bot.py — main loop, packet dispatch, connection management

Removed duplicated donglora library code (cobs_frame, read_frame,
encode_radio_config, encode_command, decode_response) — now uses
donglora.send/recv/connect directly.

Added MeshCore Public channel (fixed PSK 8b3387e9...cd72) and
expanded built-in hashtag channels: #nodeops, #repeater-ops, #test,
#weather, #protest, #general, #bot, #net, #emcomm.

Replaced ANSI print() with stdlib logging (color formatter for TTY).
Fixed dependency (donglora-python -> donglora). Strict ruff linting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:34:18 -06:00

479 lines
16 KiB
Python

"""Main bot loop, packet dispatch, ADVERT scheduling, connection management, CLI."""
from __future__ import annotations
import logging
import os
import sys
import time
from typing import Any, ClassVar
import serial
import donglora as dl
from orac.ai import call_claude, extract_trigger_query, rate_limit_message
from orac.constants import (
ADVERT_INTERVAL,
BOT_NAME,
MAX_DM_TEXT,
MAX_RESPONSE_CHARS,
RADIO_CONFIG,
TRIGGERS,
)
from orac.crypto import (
channel_count,
init_channels,
init_identity,
my_hash,
pubkey_bytes,
)
from orac.meshcore import (
build_ack_packet,
build_advert_packet,
build_dm_packet,
build_grp_channel_packet,
build_login_response_packet,
build_path_return_packet,
compute_ack_hash,
get_route,
has_pending_dms,
is_dm_duplicate,
is_duplicate,
is_pending_expired,
learn_route,
lookup_node_by_hash,
node_name,
parse_header_and_path,
pop_pending_dms,
queue_pending_dm,
register_node,
route_name,
try_decode_advert,
try_decrypt_anon_req,
try_decrypt_dm,
try_decrypt_grp,
)
from orac.state import (
get_channel_history,
get_dm_history,
known_node_count,
load_state,
record_channel_msg,
record_dm_msg,
save_state,
)
log = logging.getLogger("orac")
# ── Rate limiting (bot-level timers) ─────────────────────────────
_last_response: dict[str, float] = {}
_last_global_response: float = 0.0
_last_rate_limit_reply: dict[str, float] = {}
def _rate_limit_check(key: str) -> bool:
"""Check if we're allowed to respond. Returns True if OK."""
global _last_global_response
from orac.constants import RATE_LIMIT_DM, RATE_LIMIT_GLOBAL, RATE_LIMIT_PER_SENDER
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
return not (key in _last_response and now - _last_response[key] < limit)
def _rate_limit_reply_ok(key: str) -> bool:
"""Check if we should send a rate-limit reply (not too often)."""
from orac.constants import RATE_LIMIT_REPLY_COOLDOWN
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) -> None:
"""Record that we just responded."""
global _last_global_response
now = time.monotonic()
_last_response[key] = now
_last_global_response = now
# ── Transmit helpers ─────────────────────────────────────────────
def _transmit_packet(conn: Any, packet: bytes, label: str = "Transmit") -> None: # noqa: ARG001
"""Transmit a raw MeshCore packet via the donglora library."""
try:
resp = dl.send(conn, "Transmit", payload=packet)
if resp["type"] == "Timeout":
log.error("TX failed: no response from radio")
elif resp["type"] == "Error":
log.error("TX failed: %s", resp)
except Exception as e:
log.error("TX exception: %s", e)
def _send_advert(conn: Any) -> None:
"""Transmit our ADVERT."""
packet = build_advert_packet()
log.info("Sending ADVERT as %s (hash=0x%02x)", BOT_NAME, my_hash())
_transmit_packet(conn, packet, label="ADVERT")
def _grp_transmit(conn: Any, channel_name: str, sender: str, text: str) -> None:
"""Encrypt and transmit a GRP_TXT on a channel."""
packet = build_grp_channel_packet(channel_name, sender, text)
if packet is None:
log.error("TX failed: unknown channel %s", channel_name)
return
_transmit_packet(conn, packet)
def _dm_transmit(conn: Any, peer_pubkey: bytes, text: str) -> None:
"""Encrypt and transmit a DM with simulated typing delay."""
delay = min(max(len(text) * 0.04, 1.0), 5.0)
time.sleep(delay)
packet = build_dm_packet(peer_pubkey, text)
peer = node_name(peer_pubkey.hex())
route_str = "direct" if get_route(peer_pubkey[0]) else "flood"
_transmit_packet(conn, packet, label=f"DM({route_str})->{peer}")
def _send_ack(conn: Any, ack_crc: bytes, dest_hash: int) -> None:
"""Send an ACK with a brief delay for the DM flood to clear."""
time.sleep(0.5)
packet = build_ack_packet(ack_crc, dest_hash)
_transmit_packet(conn, packet, label="ACK")
def _send_path_return(
conn: Any, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int
) -> None:
"""Send a PATH return so the sender can learn a direct route to us."""
packet = build_path_return_packet(peer_pubkey, path_hops, hash_size)
_transmit_packet(conn, packet, label="PATH")
def _send_login_response(conn: Any, peer_pubkey: bytes) -> None:
"""Send a login OK response."""
packet = build_login_response_packet(peer_pubkey)
_transmit_packet(conn, packet, label=f"LOGIN_RESP->{node_name(peer_pubkey.hex())}")
# ── Pending DM processing ───────────────────────────────────────
def _process_pending_dms(conn: Any, node_hash: int, peer_label: str) -> None:
"""Process queued DMs that were waiting for an ADVERT or ANON_REQ."""
pending = pop_pending_dms(node_hash)
if not pending:
return
log.info("Processing %d pending DM(s) from %s", len(pending), peer_label)
for pending_payload, ts in pending:
if is_pending_expired(ts):
continue
dm_result = try_decrypt_dm(pending_payload)
if dm_result is None:
continue
peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result
pk_hex = peer_pubkey.hex()
_send_ack(conn, compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0])
if is_dm_duplicate(pk_hex, dm_text):
continue
log.info("DM(flood) from %s: %s", peer_name, 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"
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
_dm_transmit(conn, peer_pubkey, response)
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(f"dm:{pk_hex}")
# ── Packet dispatch ──────────────────────────────────────────────
def _handle_advert(conn: Any, raw_payload: bytes) -> None:
"""Handle an incoming ADVERT packet."""
result = try_decode_advert(raw_payload)
if result is None:
return
adv_pubkey, name = result
if adv_pubkey == pubkey_bytes():
return # ignore our own ADVERT echo
register_node(adv_pubkey, name)
log.info("ADVERT %s (hash=0x%02x pk=%s...)", name, adv_pubkey[0], adv_pubkey.hex()[:16])
if has_pending_dms(adv_pubkey[0]):
_process_pending_dms(conn, adv_pubkey[0], name)
def _handle_grp_txt(conn: Any, raw_payload: bytes) -> None:
"""Handle an incoming GRP_TXT packet."""
result = try_decrypt_grp(raw_payload)
if result is None:
return
channel_name, text = result
log.info("GRP_TXT %s %s", channel_name, text)
record_channel_msg(channel_name, text)
if ": " not in text:
return
sender, _, body = text.partition(": ")
if sender == BOT_NAME:
return
query = extract_trigger_query(body)
if query is None:
return
log.info(">>> Query from %s on %s: %s", sender, channel_name, query)
rl_key = f"ch:{channel_name}:{sender}"
if not _rate_limit_check(rl_key):
log.warning("Rate limited: %s on %s", sender, channel_name)
if _rate_limit_reply_ok(f"ch:{channel_name}"):
msg = rate_limit_message()
log.info("<<< %s: %s", BOT_NAME, msg)
_grp_transmit(conn, channel_name, BOT_NAME, msg)
_rate_limit_record(rl_key)
return
history = get_channel_history(channel_name)
response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}")
if response is None:
return
log.info("<<< %s: %s", BOT_NAME, response)
_grp_transmit(conn, channel_name, BOT_NAME, response)
record_channel_msg(channel_name, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
def _handle_anon_req(
conn: Any,
raw_payload: bytes,
path_hops: list[bytes],
hash_size: int,
) -> None:
"""Handle an incoming ANON_REQ (login) packet."""
result = try_decrypt_anon_req(raw_payload)
if result is None:
return
peer_pubkey, peer_name, _plaintext = result
learn_route(peer_pubkey[0], path_hops, hash_size)
log.info("LOGIN from %s -- sending login OK", peer_name)
_send_login_response(conn, peer_pubkey)
if has_pending_dms(peer_pubkey[0]):
_process_pending_dms(conn, peer_pubkey[0], peer_name)
def _handle_txt_msg(
conn: Any,
raw_payload: bytes,
route_type: int,
path_hops: list[bytes],
hash_size: int,
) -> None:
"""Handle an incoming TXT_MSG (DM) packet."""
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):
is_first = queue_pending_dm(src_hash, raw_payload)
if is_first:
log.info("Queued DM from unknown 0x%02x, waiting for their ADVERT", src_hash)
return
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(conn, ack_crc, peer_pubkey[0])
# Send PATH return so sender can learn a direct route to us
_send_path_return(conn, peer_pubkey, path_hops, hash_size)
pk_hex = peer_pubkey.hex()
if is_dm_duplicate(pk_hex, dm_text):
return
log.info("DM(%s) from %s: %s", route_name(route_type), peer_name, 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):
log.warning("Rate limited DM: %s", peer_name)
return
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:
return
rt = "direct" if get_route(peer_pubkey[0]) else "flood"
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
_dm_transmit(conn, peer_pubkey, response)
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
_rate_limit_record(rl_key)
# ── Bot loop ─────────────────────────────────────────────────────
def bot_loop(conn: Any) -> None:
"""Main receive loop: read packets and dispatch."""
dl.send(conn, "Ping")
dl.send(conn, "SetConfig", config=RADIO_CONFIG)
dl.send(conn, "StartRx")
_send_advert(conn)
last_advert = time.monotonic()
triggers_str = ", ".join(TRIGGERS)
log.info("%s listening (Ctrl+C to stop)", BOT_NAME)
log.info("Channels: %d | Triggers: %s | DMs: enabled", channel_count(), triggers_str)
conn.timeout = 1
while True:
# Periodic ADVERT
now = time.monotonic()
if now - last_advert >= ADVERT_INTERVAL:
_send_advert(conn)
last_advert = now
pkt = dl.recv(conn)
if pkt is None:
continue
packet: bytes = pkt["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:
src_hash: int | None = None
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]
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
# Dispatch by payload type
if payload_type == 0x03: # ACK -- ignore (fire-and-forget)
continue
elif payload_type == 0x04: # ADVERT
_handle_advert(conn, raw_payload)
elif payload_type == 0x05: # GRP_TXT
_handle_grp_txt(conn, raw_payload)
elif payload_type == 0x07: # ANON_REQ (login)
_handle_anon_req(conn, raw_payload, path_hops, hash_size)
elif payload_type == 0x02: # TXT_MSG (DM)
_handle_txt_msg(conn, raw_payload, route_type, path_hops, hash_size)
# ── Logging setup ────────────────────────────────────────────────
class _ColorFormatter(logging.Formatter):
"""Log formatter that adds ANSI colors when stderr is a TTY."""
_COLORS: ClassVar[dict[int, str]] = {
logging.DEBUG: "\033[2m", # dim
logging.INFO: "\033[32m", # green
logging.WARNING: "\033[33m", # yellow
logging.ERROR: "\033[31m", # red
logging.CRITICAL: "\033[1;31m", # bold red
}
_RESET = "\033[0m"
def __init__(self, use_color: bool = True) -> None:
super().__init__(fmt=" %(message)s")
self._use_color = use_color
def format(self, record: logging.LogRecord) -> str:
msg = super().format(record)
if self._use_color:
color = self._COLORS.get(record.levelno, "")
return f"{color}{msg}{self._RESET}"
return msg
def _setup_logging() -> None:
"""Configure the orac logger with colored console output."""
logger = logging.getLogger("orac")
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(_ColorFormatter(use_color=sys.stderr.isatty()))
logger.addHandler(handler)
# ── Entry point ──────────────────────────────────────────────────
def main() -> None:
"""CLI entry point."""
_setup_logging()
if not os.environ.get("ANTHROPIC_API_KEY"):
log.error("ANTHROPIC_API_KEY environment variable not set")
sys.exit(1)
init_identity()
init_channels()
load_state()
log.info("%s -- MeshCore AI Bot", BOT_NAME)
log.info(
"Max GRP response: %d chars | Max DM response: %d chars", MAX_RESPONSE_CHARS, MAX_DM_TEXT
)
log.info("Channels: %d | Known nodes: %d", channel_count(), known_node_count())
port = sys.argv[1] if len(sys.argv) > 1 else None
while True:
try:
log.info("Connecting...")
conn = dl.connect(port=port, timeout=2)
log.info("Connected")
bot_loop(conn)
except (serial.SerialException, ConnectionError, OSError) as e:
log.error("Disconnected: %s", e)
log.info("Reconnecting when device reappears...")
time.sleep(1)
except KeyboardInterrupt:
print()
save_state()
try:
conn.timeout = 2 # type: ignore[possibly-undefined]
dl.send(conn, "StopRx") # type: ignore[possibly-undefined]
except Exception:
pass
break