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>
This commit is contained in:
11 files changed
+2350
-1417
No files matched your search
+478
@@ -0,0 +1,478 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user