refactor: split bot.py into runtime/dispatch/worker modules; add retry scheduler, route TTL, ack tracking, follow-up screener, structured event log, and test suite
This commit is contained in:
24 files changed
+3531
-434
No files matched your search
+165
-392
@@ -1,19 +1,19 @@
|
||||
"""Main bot loop, packet dispatch, ADVERT scheduling, connection management, CLI."""
|
||||
"""Orchestration: wire up runtime + dispatch + worker, manage connect loop + signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
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 import events, logfmt
|
||||
from orac.constants import (
|
||||
ADVERT_INTERVAL,
|
||||
BOT_NAME,
|
||||
MAX_DM_TEXT,
|
||||
MAX_RESPONSE_CHARS,
|
||||
@@ -27,419 +27,192 @@ from orac.crypto import (
|
||||
my_hash,
|
||||
pubkey_bytes,
|
||||
)
|
||||
from orac.dispatch import RateLimiter, RxRouter, make_advert_pusher
|
||||
from orac.logfmt import boot
|
||||
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,
|
||||
build_dm_packet_with_plaintext,
|
||||
compute_ack_hash,
|
||||
forget_route,
|
||||
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,
|
||||
route_table_size,
|
||||
)
|
||||
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
|
||||
from orac.runtime import IOThread, Metrics, RetryScheduler, TxQueue
|
||||
from orac.state import known_node_count, load_state, save_state
|
||||
from orac.worker import Worker
|
||||
|
||||
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] = {}
|
||||
# ── Retry rebuild callback ───────────────────────────────────────
|
||||
|
||||
|
||||
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
|
||||
def _make_rebuild_reply(metrics: Metrics):
|
||||
"""Return a rebuild-reply fn for the RetryScheduler."""
|
||||
|
||||
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 _rebuild(entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
|
||||
# `attempt` is 1-indexed (1 = first send). The wire byte uses attempt-1
|
||||
# so attempt=2 (first retry) sets txt_type_attempt byte = 1.
|
||||
attempt_byte = max(0, min(63, attempt - 1))
|
||||
packet, plaintext = build_dm_packet_with_plaintext(
|
||||
entry.peer_pk,
|
||||
entry.text,
|
||||
force_flood=force_flood,
|
||||
ts=entry.first_ts,
|
||||
attempt=attempt_byte,
|
||||
)
|
||||
new_ack = compute_ack_hash(plaintext, pubkey_bytes())
|
||||
metrics.inc("reply_rebuilt")
|
||||
return packet, new_ack
|
||||
|
||||
return _rebuild
|
||||
|
||||
|
||||
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
|
||||
# ── Orchestration ────────────────────────────────────────────────
|
||||
|
||||
|
||||
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
|
||||
class BotRuntime:
|
||||
"""Holds the per-connection runtime: threads + queues + state tables."""
|
||||
|
||||
def __init__(self, conn) -> None:
|
||||
self.conn = conn
|
||||
self.metrics = Metrics()
|
||||
self.tx_queue = TxQueue()
|
||||
self.pending_acks = PendingAckTable()
|
||||
self.reply_cache = ReplyCache()
|
||||
self.rate_limiter = RateLimiter()
|
||||
|
||||
self.worker = Worker(
|
||||
tx_queue=self.tx_queue,
|
||||
pending_acks=self.pending_acks,
|
||||
reply_cache=self.reply_cache,
|
||||
metrics=self.metrics,
|
||||
on_reply_recorded=self.rate_limiter.record,
|
||||
)
|
||||
|
||||
self.rx_router = RxRouter(
|
||||
tx_queue=self.tx_queue,
|
||||
worker=self.worker,
|
||||
pending_acks=self.pending_acks,
|
||||
reply_cache=self.reply_cache,
|
||||
rate_limiter=self.rate_limiter,
|
||||
metrics=self.metrics,
|
||||
)
|
||||
|
||||
self.retry_scheduler = RetryScheduler(
|
||||
pending=self.pending_acks,
|
||||
tx_queue=self.tx_queue,
|
||||
metrics=self.metrics,
|
||||
rebuild_reply=_make_rebuild_reply(self.metrics),
|
||||
forget_route_fn=forget_route,
|
||||
has_route_fn=lambda h: get_route(h) is not None,
|
||||
event_emit=events.emit,
|
||||
)
|
||||
|
||||
self.advert_pusher = make_advert_pusher(build_advert_packet, self.metrics)
|
||||
|
||||
self.io_thread = IOThread(
|
||||
conn=self.conn,
|
||||
tx_queue=self.tx_queue,
|
||||
retry_sched=self.retry_scheduler,
|
||||
rx_handler=self.rx_router.handle,
|
||||
advert_fn=self.advert_pusher,
|
||||
metrics=self.metrics,
|
||||
gauge_collector=self._collect_gauges,
|
||||
)
|
||||
|
||||
# -- gauge dumper called by IOThread every 60s ----------------
|
||||
|
||||
def _collect_gauges(self) -> None:
|
||||
self.metrics.gauge("tx_queue_depth", self.tx_queue.depth())
|
||||
self.metrics.gauge("pending_ack_depth", self.pending_acks.depth())
|
||||
self.metrics.gauge("reply_cache_depth", self.reply_cache.depth())
|
||||
self.metrics.gauge("route_table_size", route_table_size())
|
||||
|
||||
# -- lifecycle ------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
self.worker.start()
|
||||
self.io_thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.io_thread.stop()
|
||||
self.worker.stop()
|
||||
|
||||
def join(self, timeout: float = 5.0) -> None:
|
||||
"""Wait for both threads to exit. Bounded so Ctrl+C can't hang us forever."""
|
||||
self.io_thread.join(timeout=timeout)
|
||||
self.worker.join(timeout=timeout)
|
||||
|
||||
def dump_metrics(self) -> None:
|
||||
self._collect_gauges()
|
||||
boot("METRICS %s", self.metrics.summary())
|
||||
|
||||
|
||||
# ── Transmit helpers ─────────────────────────────────────────────
|
||||
# ── Single-instance signal wiring ───────────────────────────────
|
||||
|
||||
_active_runtime: BotRuntime | None = None
|
||||
|
||||
|
||||
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 _handle_sigusr1(signum: int, _frame: object) -> None: # noqa: ARG001
|
||||
rt = _active_runtime
|
||||
if rt is not None:
|
||||
rt.dump_metrics()
|
||||
|
||||
|
||||
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")
|
||||
# ── Connect loop ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
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 connect_and_run(port: str | None) -> None:
|
||||
"""Connect to a donglora dongle, spin up the runtime, and run until disconnect."""
|
||||
global _active_runtime
|
||||
|
||||
boot("Connecting...")
|
||||
conn = dl.connect(port=port, timeout=2)
|
||||
boot("Connected")
|
||||
|
||||
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
|
||||
boot("%s listening (Ctrl+C to stop)", BOT_NAME)
|
||||
boot(
|
||||
"Channels: %d | Triggers: %s | DMs: enabled",
|
||||
channel_count(),
|
||||
triggers_str,
|
||||
)
|
||||
|
||||
while True:
|
||||
# Periodic ADVERT
|
||||
now = time.monotonic()
|
||||
if now - last_advert >= ADVERT_INTERVAL:
|
||||
_send_advert(conn)
|
||||
last_advert = now
|
||||
runtime = BotRuntime(conn)
|
||||
_active_runtime = runtime
|
||||
|
||||
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)
|
||||
try:
|
||||
runtime.start()
|
||||
# Block main thread until IOThread exits (via stop() or crash).
|
||||
# Short timed joins keep Ctrl+C responsive.
|
||||
while runtime.io_thread.is_alive():
|
||||
runtime.io_thread.join(timeout=0.5)
|
||||
if runtime.io_thread.last_error is not None:
|
||||
raise runtime.io_thread.last_error
|
||||
except KeyboardInterrupt:
|
||||
boot("Shutting down...")
|
||||
raise
|
||||
finally:
|
||||
runtime.stop()
|
||||
runtime.join(timeout=3.0)
|
||||
runtime.dump_metrics()
|
||||
_active_runtime = None
|
||||
with contextlib.suppress(Exception):
|
||||
conn.timeout = 2
|
||||
dl.send(conn, "StopRx")
|
||||
|
||||
|
||||
# ── Entry point ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
_setup_logging()
|
||||
logfmt.setup_logging()
|
||||
|
||||
if not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
log.error("ANTHROPIC_API_KEY environment variable not set")
|
||||
@@ -447,32 +220,32 @@ def main() -> None:
|
||||
|
||||
init_identity()
|
||||
init_channels()
|
||||
events.init()
|
||||
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
|
||||
boot("%s -- MeshCore AI Bot", BOT_NAME)
|
||||
boot(
|
||||
"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())
|
||||
boot("Channels: %d | Known nodes: %d", channel_count(), known_node_count())
|
||||
boot("Pubkey hash: 0x%02x", my_hash())
|
||||
|
||||
# SIGUSR1 dumps metrics; not available on all platforms (e.g. Windows).
|
||||
with contextlib.suppress(ValueError, AttributeError):
|
||||
signal.signal(signal.SIGUSR1, _handle_sigusr1)
|
||||
|
||||
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)
|
||||
connect_and_run(port)
|
||||
except (serial.SerialException, ConnectionError, OSError) as e:
|
||||
log.error("Disconnected: %s", e)
|
||||
log.info("Reconnecting when device reappears...")
|
||||
boot("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