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
@@ -6,12 +6,12 @@ default: run
|
||||
_ensure_tools:
|
||||
@mise trust --yes . 2>/dev/null; mise install --quiet
|
||||
|
||||
# Run the AI bot
|
||||
# Run the AI bot (optionally pass a serial port: `just run /dev/ttyACM0`)
|
||||
run *args: _ensure_tools
|
||||
@uv run ai_bot.py {{args}}
|
||||
|
||||
# Run all checks (fmt, lint)
|
||||
check: fmt-check lint
|
||||
# Run the full gauntlet: format-check, lint, type-check, tests
|
||||
check: fmt-check lint typecheck test
|
||||
|
||||
# Format code
|
||||
fmt: _ensure_tools
|
||||
@@ -21,6 +21,39 @@ fmt: _ensure_tools
|
||||
fmt-check: _ensure_tools
|
||||
@uv run ruff format --check .
|
||||
|
||||
# Lint
|
||||
# Lint (ruff)
|
||||
lint: _ensure_tools
|
||||
@uv run ruff check .
|
||||
|
||||
# Auto-fix lint where possible
|
||||
lint-fix: _ensure_tools
|
||||
@uv run ruff check --fix .
|
||||
|
||||
# Static type check (pyright)
|
||||
typecheck: _ensure_tools
|
||||
@uv run pyright orac tests ai_bot.py
|
||||
|
||||
# Run the test suite
|
||||
test *args: _ensure_tools
|
||||
@uv run pytest {{args}}
|
||||
|
||||
# Run tests with verbose output
|
||||
test-v: _ensure_tools
|
||||
@uv run pytest -v
|
||||
|
||||
# Dump live metrics from a running bot (sends SIGUSR1 by PID; pass the pid)
|
||||
metrics pid: _ensure_tools
|
||||
@kill -USR1 {{pid}}
|
||||
@echo "Sent SIGUSR1 to PID {{pid}}; check the bot's stderr for METRICS line"
|
||||
|
||||
# Tail the structured event log (JSONL)
|
||||
events:
|
||||
@tail -F ~/.donglora/orac-events.jsonl
|
||||
|
||||
# Pretty-print the events log with jq
|
||||
events-pretty:
|
||||
@tail -F ~/.donglora/orac-events.jsonl | jq .
|
||||
|
||||
# Clean cached artifacts
|
||||
clean:
|
||||
@rm -rf .pytest_cache .ruff_cache __pycache__ orac/__pycache__ tests/__pycache__
|
||||
+94
-10
@@ -5,12 +5,23 @@ from __future__ import annotations
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic.types import (
|
||||
MessageParam,
|
||||
ServerToolUseBlock,
|
||||
TextBlock,
|
||||
ToolUnionParam,
|
||||
)
|
||||
|
||||
from orac.constants import (
|
||||
AT_MENTIONS,
|
||||
BOT_NAME,
|
||||
CLAUDE_TIMEOUT_S,
|
||||
MAX_RESPONSE_CHARS,
|
||||
SCREENER_MODEL,
|
||||
SCREENER_TIMEOUT_S,
|
||||
SYSTEM_PROMPT,
|
||||
TRIGGERS,
|
||||
)
|
||||
@@ -38,6 +49,7 @@ def _shorten_with_claude(text: str, max_chars: int) -> str:
|
||||
resp = _get_client().messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=256,
|
||||
timeout=CLAUDE_TIMEOUT_S,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
@@ -45,7 +57,8 @@ def _shorten_with_claude(text: str, max_chars: int) -> str:
|
||||
}
|
||||
],
|
||||
)
|
||||
shortened: str = resp.content[0].text.strip()
|
||||
first = resp.content[0] if resp.content else None
|
||||
shortened = first.text.strip() if isinstance(first, TextBlock) else text
|
||||
except Exception as e:
|
||||
log.error("Shorten API error: %s", e)
|
||||
shortened = text
|
||||
@@ -61,7 +74,7 @@ def _extract_text(resp: anthropic.types.Message) -> str:
|
||||
"""Extract the last non-empty text block from a Claude response."""
|
||||
text = ""
|
||||
for block in resp.content:
|
||||
if block.type == "text" and block.text.strip():
|
||||
if isinstance(block, TextBlock) and block.text.strip():
|
||||
text = block.text.strip()
|
||||
return text
|
||||
|
||||
@@ -94,8 +107,10 @@ def call_claude(
|
||||
else:
|
||||
user_content = f"[seed:{nonce}] {sender} says: {query}"
|
||||
|
||||
messages: list[dict[str, object]] = [{"role": "user", "content": user_content}]
|
||||
tools: list[dict[str, str]] = [{"type": "web_search_20260209", "name": "web_search"}]
|
||||
messages: list[MessageParam] = [{"role": "user", "content": user_content}]
|
||||
tools: list[ToolUnionParam] = [
|
||||
cast("ToolUnionParam", {"type": "web_search_20260209", "name": "web_search"})
|
||||
]
|
||||
|
||||
text = ""
|
||||
for round_num in range(3):
|
||||
@@ -103,6 +118,7 @@ def call_claude(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=4096,
|
||||
temperature=1.0,
|
||||
timeout=CLAUDE_TIMEOUT_S,
|
||||
system=prompt,
|
||||
tools=tools,
|
||||
messages=messages,
|
||||
@@ -119,14 +135,14 @@ def call_claude(
|
||||
|
||||
log.info("(web search round %d)...", round_num + 1)
|
||||
messages.append({"role": "assistant", "content": resp.content})
|
||||
tool_results: list[dict[str, str]] = []
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for block in resp.content:
|
||||
if hasattr(block, "id") and block.type == "server_tool_use":
|
||||
if isinstance(block, ServerToolUseBlock):
|
||||
tool_results.append(
|
||||
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
|
||||
)
|
||||
if tool_results:
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
messages.append({"role": "user", "content": cast("Any", tool_results)})
|
||||
else:
|
||||
break
|
||||
|
||||
@@ -155,6 +171,7 @@ def rate_limit_message() -> str:
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=100,
|
||||
temperature=1.0,
|
||||
timeout=CLAUDE_TIMEOUT_S,
|
||||
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) "
|
||||
@@ -163,9 +180,11 @@ def rate_limit_message() -> str:
|
||||
),
|
||||
messages=[{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}],
|
||||
)
|
||||
text: str = resp.content[0].text.strip().strip("\"'")
|
||||
if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text:
|
||||
return text
|
||||
first = resp.content[0] if resp.content else None
|
||||
if isinstance(first, TextBlock):
|
||||
text = first.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."
|
||||
@@ -186,3 +205,68 @@ def extract_trigger_query(text: str) -> str | None:
|
||||
if query:
|
||||
return query
|
||||
return None
|
||||
|
||||
|
||||
# ── Channel follow-up screener ─────────────────────────────────
|
||||
|
||||
_SCREENER_SYSTEM_PROMPT = f"""\
|
||||
You are a one-shot classifier. Your job is to decide whether the LATEST message
|
||||
in a group chat is addressed (implicitly) to an AI assistant named {BOT_NAME}.
|
||||
|
||||
Reply with exactly one token: YES or NO. No punctuation, no explanation.
|
||||
|
||||
Say YES only when ALL of the following hold:
|
||||
- {BOT_NAME} has been part of the recent conversation (see history).
|
||||
- The latest message is plausibly a direct follow-up to {BOT_NAME}'s most
|
||||
recent message: answering a question {BOT_NAME} asked, providing info
|
||||
{BOT_NAME} requested, pushing back on {BOT_NAME}'s claim, or continuing
|
||||
a thread {BOT_NAME} was participating in.
|
||||
- No other human in the chat is the more obvious addressee.
|
||||
|
||||
Say NO if:
|
||||
- The message is between two other humans, about an unrelated topic.
|
||||
- The message could go either way. When uncertain, NO. People hate bot noise.
|
||||
- The message just mentions or references {BOT_NAME} third-person without
|
||||
asking anything ("someone could ask {BOT_NAME}").
|
||||
- {BOT_NAME} wasn't in the recent conversation at all.
|
||||
|
||||
Err strongly toward NO. It's much better to miss a follow-up than to barge into
|
||||
a conversation where nobody invited you."""
|
||||
|
||||
|
||||
def screen_channel_message(
|
||||
channel_name: str,
|
||||
sender: str,
|
||||
text: str,
|
||||
history: list[str],
|
||||
) -> bool:
|
||||
"""Classify whether *text* (in *channel_name*) is a follow-up for Orac.
|
||||
|
||||
Uses a cheap Haiku call. Returns True for YES, False for NO/error.
|
||||
"""
|
||||
try:
|
||||
history_block = "\n".join(history[-10:]) if history else "(no prior messages)"
|
||||
user_content = (
|
||||
f"Channel: {channel_name}\n\n"
|
||||
f"Recent history (oldest first):\n{history_block}\n\n"
|
||||
f"Latest message:\n{sender}: {text}\n\n"
|
||||
f"Verdict (YES or NO):"
|
||||
)
|
||||
resp = _get_client().messages.create(
|
||||
model=SCREENER_MODEL,
|
||||
max_tokens=4,
|
||||
temperature=0.0,
|
||||
timeout=SCREENER_TIMEOUT_S,
|
||||
system=_SCREENER_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": user_content}],
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("Screener API error: %s", e)
|
||||
return False
|
||||
|
||||
first = resp.content[0] if resp.content else None
|
||||
if not isinstance(first, TextBlock):
|
||||
return False
|
||||
verdict = first.text.strip().upper()
|
||||
# Accept exact "YES" or a response starting with YES; anything else is NO.
|
||||
return verdict.startswith("YES")
|
||||
+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
|
||||
+134
-4
@@ -65,10 +65,31 @@ 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.
|
||||
You can see recent messages for context. Use the chat history to understand what
|
||||
people mean. "what do you think?" refers to whatever was just discussed. "tell me
|
||||
more" means elaborate on the recent topic.
|
||||
|
||||
Social awareness is YOUR TOP PRIORITY — above cleverness, above information density.
|
||||
Before every response, work out:
|
||||
- Who is actually talking, and to whom? Are they asking you, or about you?
|
||||
- What's the vibe: casual banter, technical help, emergency, venting, inside joke?
|
||||
- What's the group's energy? Are you being invited in, or just name-dropped?
|
||||
- If someone said "you could always ask @Orac" (or similar), that's a social nudge,
|
||||
NOT a request for a factual answer. Acknowledge lightly, or offer to help if the
|
||||
invitation was sincere — don't barge in with an essay.
|
||||
- If the conversation is jokey, match the tone. If serious, be substantive.
|
||||
If someone's frustrated, don't be flippant. Read the room like a person would.
|
||||
- When people are talking among themselves and you're tagged in passing, a short
|
||||
witty nod is often the right move — or silence would be, if you could stay silent.
|
||||
- When you ARE being asked something directly, answer crisply.
|
||||
|
||||
NEVER assume location. Weather, traffic, time zones, sunset, local services —
|
||||
all of these are useless without knowing where the person is. If someone asks
|
||||
"what's the weather?" or anything else location-sensitive without telling you
|
||||
where, ask them. A short quip is fine: "Where?" or "Drop a city/airport code
|
||||
and I'll tell you." Do NOT guess a city. Do NOT default to Denver or anywhere
|
||||
else. Only answer a location-dependent question after the user has provided
|
||||
the location (either in the current turn or visible in recent history).
|
||||
|
||||
Rules:
|
||||
- One sentence or short phrase. Never more.
|
||||
@@ -76,6 +97,7 @@ Rules:
|
||||
- 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.
|
||||
- Cynicism is fine in small doses; punching-down or mean-spirited is not.
|
||||
- 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.\
|
||||
@@ -91,6 +113,114 @@ DM_DEDUP_TTL: float = 60 # seconds
|
||||
|
||||
RESP_SERVER_LOGIN_OK: int = 0x01
|
||||
|
||||
# ── DM reliability: IO loop ──────────────────────────────────────
|
||||
|
||||
IO_POLL_TIMEOUT_S: float = 0.2
|
||||
RX_DRAIN_PER_TICK: int = 8
|
||||
|
||||
# Donglora's send() reads the TxDone response using the serial timeout; LoRa
|
||||
# airtime at SF7/62.5 kHz for a max-size 180 B DM is ~425 ms, plus up to
|
||||
# 100 ms of CAD retries in the firmware. 2 s gives a comfortable margin.
|
||||
TX_RESPONSE_TIMEOUT_S: float = 2.0
|
||||
|
||||
# ── DM reliability: ACK scheduling ───────────────────────────────
|
||||
# Small jitter below single-ACK airtime (~89 ms at SF7/62.5 kHz/CR4/5)
|
||||
# decouples us from other responders, without delaying the sender's
|
||||
# firmware retry clock.
|
||||
ACK_JITTER_MIN_S: float = 0.03
|
||||
ACK_JITTER_MAX_S: float = 0.09
|
||||
PATH_RETURN_DELAY_S: float = 0.15
|
||||
|
||||
# Multi-ACK protocol (upstream commits b1ca3d1 + 5881b04, `multi_acks` pref).
|
||||
# When > 0, N extra ACKs are emitted as PAYLOAD_TYPE_MULTIPART (0x0A) packets
|
||||
# with remaining-count nibbles counting down to 1, followed by a final plain
|
||||
# PAYLOAD_TYPE_ACK (0x03). Each successive packet is 300 ms + small jitter
|
||||
# after the previous.
|
||||
#
|
||||
# Firmware restricts extra ACKs to direct-routed packets only (see
|
||||
# Mesh::routeDirectRecvAcks). We do the same: if no learned direct route to
|
||||
# the peer exists, we fall back to a single plain ACK and skip MULTIPART,
|
||||
# matching upstream semantics.
|
||||
#
|
||||
# Upstream default is 0 (multi_acks pref = 0). MeshCore One's iOS app surfaces
|
||||
# this as the "2 ACKs" toggle (multi_acks=1 -> 1 MULTIPART + 1 ACK = 2 total).
|
||||
MULTI_ACK_COUNT: int = 0
|
||||
MULTI_ACK_SPACING_S: float = 0.3
|
||||
|
||||
# ── DM reliability: reply scheduling ─────────────────────────────
|
||||
# Initial reply jitter keeps the reply from racing our own ACK/PATH
|
||||
# on the air and below any retry window.
|
||||
REPLY_INITIAL_JITTER_MIN_S: float = 0.05
|
||||
REPLY_INITIAL_JITTER_MAX_S: float = 0.20
|
||||
|
||||
# Retry delays between attempts. Index i = delay AFTER firing attempt (i+1)
|
||||
# before attempt (i+2). With REPLY_MAX_ATTEMPTS=5 we need 4 entries (for
|
||||
# attempts 2-5). Schedule mirrors pymc's 3-direct + 2-flood pattern.
|
||||
#
|
||||
# Timeline at current radio config (SF7/62.5 kHz/CR4/5, ~3.3 s 2-hop RTT):
|
||||
# Attempt 1 (initial) at T=0
|
||||
# Attempt 2 (direct) at T=6 (~1.8x 2-hop RTT headroom)
|
||||
# Attempt 3 (direct) at T=20 (bigger gap for congested relays)
|
||||
# Attempt 4 (flood, path-reset) at T=50 (mesh quiesce window)
|
||||
# Attempt 5 (flood) at T=95 (last hail-Mary after a long wait)
|
||||
# Give up (sweep) at T=125 (~30s past the last attempt for ACK)
|
||||
RETRY_SCHEDULE_S: tuple[float, ...] = (6.0, 14.0, 30.0, 45.0)
|
||||
REPLY_MAX_ATTEMPTS: int = 5 # 1 initial + 2 direct retries + 2 flood retries
|
||||
REPLY_FLOOD_FIRST_ATTEMPT: int = 4 # 1-indexed: first attempt that forces flood
|
||||
|
||||
# ── DM reliability: state bounds ─────────────────────────────────
|
||||
# TTL must be beyond the final retry's transmit time plus enough for the
|
||||
# reply's ACK to arrive: 6+14+30+45 = 95s to final retry, +30s for ACK.
|
||||
PENDING_ACK_TTL_S: float = 125.0
|
||||
PENDING_ACK_CAP: int = 64
|
||||
REPLY_CACHE_CAP: int = 32
|
||||
REPLY_CACHE_TTL_S: float = 300.0 # matches PENDING_DM_TTL
|
||||
|
||||
# Duplicate DMs arriving within this window *with a cached reply* trigger a
|
||||
# cached-reply resend (interpreted as loss recovery). Outside this window, a
|
||||
# duplicate text is treated as the peer legitimately re-asking after giving up,
|
||||
# and falls through to normal text-level dedup (silent drop inside DM_DEDUP_TTL
|
||||
# and a fresh Claude call beyond that).
|
||||
REPLY_CACHE_RESEND_WINDOW_S: float = 20.0
|
||||
TX_QUEUE_CAP: int = 64
|
||||
|
||||
# ── DM reliability: route table ──────────────────────────────────
|
||||
ROUTE_TTL_S: float = 1800.0 # 30 min
|
||||
|
||||
# Minimum hop-hash size (bytes) we will accept when learning a return path,
|
||||
# and the minimum we will echo back in a PATH_RETURN. 1-byte hashes have a
|
||||
# 256-way namespace that collides under any real node population; this bot
|
||||
# refuses to participate in 1-byte routing.
|
||||
MIN_HASH_SIZE: int = 2
|
||||
|
||||
# ── DM reliability: worker / Claude ──────────────────────────────
|
||||
WORKER_QUEUE_CAP: int = 8
|
||||
CLAUDE_TIMEOUT_S: float = 20.0
|
||||
|
||||
# ── Channel follow-up screener ───────────────────────────────────
|
||||
# Inexpensive yes/no classifier that decides whether a NON-TRIGGERED channel
|
||||
# message is a follow-up to Orac (we should reply) or just chatter between
|
||||
# other participants (we should not). Keeps Orac capable of natural multi-turn
|
||||
# conversation without flooding the channel with bot noise.
|
||||
|
||||
# Haiku is the cheapest Claude model — suitable for a yes/no gate.
|
||||
SCREENER_MODEL: str = "claude-haiku-4-5"
|
||||
|
||||
# How long after Orac was last active in a channel to keep screening new
|
||||
# non-triggered messages. Outside this window, non-triggered messages are
|
||||
# ignored without spending any API call.
|
||||
CHANNEL_FOLLOWUP_WINDOW_S: float = 180.0 # 3 min
|
||||
|
||||
# Per-channel minimum gap between screener API calls, to prevent spam from
|
||||
# running up cost during a burst of traffic.
|
||||
SCREENER_PER_CHANNEL_RATE_S: float = 3.0
|
||||
|
||||
# Short timeout for the screener — it returns a single token.
|
||||
SCREENER_TIMEOUT_S: float = 6.0
|
||||
|
||||
# ── Events JSONL path ────────────────────────────────────────────
|
||||
EVENTS_FILE: Path = DATA_DIR / "orac-events.jsonl"
|
||||
|
||||
# ── Channels CSV path ────────────────────────────────────────────
|
||||
# Resolved at import time relative to the project root (ai_bot.py's directory).
|
||||
CHANNELS_CSV: Path = Path(__file__).resolve().parent.parent / "channels.csv"
|
||||
+25
-4
@@ -138,12 +138,31 @@ def ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes:
|
||||
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"
|
||||
def build_peer_plaintext(text: str, ts: int | None = None, attempt: int = 0) -> bytes:
|
||||
"""Build a DM plaintext blob: timestamp(4 LE) + txt_type_attempt(1) + text + \\x00 + zero-pad to 16.
|
||||
|
||||
Using the timestamp + attempt fields from the MeshCore TXT_MSG plaintext layout.
|
||||
Callers that need the expected ACK hash must keep this plaintext.
|
||||
"""
|
||||
if ts is None:
|
||||
ts = int(time.time())
|
||||
plaintext = struct.pack("<I", ts) + bytes([attempt & 0xFF])
|
||||
plaintext += text.encode("utf-8") + b"\x00"
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
plaintext += b"\x00" * pad_len
|
||||
return plaintext + b"\x00" * pad_len
|
||||
|
||||
|
||||
def peer_encrypt(shared_secret: bytes, text: str) -> bytes:
|
||||
"""Encrypt a DM plaintext. Returns mac(2) + ciphertext.
|
||||
|
||||
Convenience wrapper; callers that also need the plaintext should use
|
||||
:func:`build_peer_plaintext` + :func:`peer_encrypt_plaintext`.
|
||||
"""
|
||||
return peer_encrypt_plaintext(shared_secret, build_peer_plaintext(text))
|
||||
|
||||
|
||||
def peer_encrypt_plaintext(shared_secret: bytes, plaintext: bytes) -> bytes:
|
||||
"""Encrypt a pre-built DM plaintext. Returns mac(2) + ciphertext."""
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = b""
|
||||
for i in range(0, len(plaintext), 16):
|
||||
@@ -302,6 +321,7 @@ def raw_peer_encrypt(shared_secret: bytes, plaintext: bytes) -> bytes:
|
||||
__all__: list[str] = [
|
||||
"aes_ecb_encrypt",
|
||||
"build_advert_payload",
|
||||
"build_peer_plaintext",
|
||||
"channel_count",
|
||||
"channel_hash",
|
||||
"channel_secret_from_hashtag",
|
||||
@@ -317,6 +337,7 @@ __all__: list[str] = [
|
||||
"parse_grp_plaintext",
|
||||
"parse_peer_plaintext",
|
||||
"peer_encrypt",
|
||||
"peer_encrypt_plaintext",
|
||||
"peer_verify_and_decrypt",
|
||||
"pubkey_bytes",
|
||||
"raw_peer_encrypt",
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
"""RX dispatch: parse incoming frames, drive ACK/PATH/worker/retry feedback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from orac import events, followup, logfmt
|
||||
from orac.ai import extract_trigger_query, rate_limit_message
|
||||
from orac.constants import (
|
||||
ACK_JITTER_MAX_S,
|
||||
ACK_JITTER_MIN_S,
|
||||
BOT_NAME,
|
||||
CHANNEL_FOLLOWUP_WINDOW_S,
|
||||
MIN_HASH_SIZE,
|
||||
MULTI_ACK_COUNT,
|
||||
MULTI_ACK_SPACING_S,
|
||||
PATH_RETURN_DELAY_S,
|
||||
RATE_LIMIT_DM,
|
||||
RATE_LIMIT_GLOBAL,
|
||||
RATE_LIMIT_PER_SENDER,
|
||||
RATE_LIMIT_REPLY_COOLDOWN,
|
||||
REPLY_CACHE_RESEND_WINDOW_S,
|
||||
SCREENER_PER_CHANNEL_RATE_S,
|
||||
)
|
||||
from orac.crypto import my_hash, pubkey_bytes
|
||||
from orac.meshcore import (
|
||||
build_ack_packet,
|
||||
build_grp_channel_packet,
|
||||
build_login_response_packet,
|
||||
build_multiack_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.reply_state import PendingAckTable, ReplyCache
|
||||
from orac.runtime import Metrics, TxItem, TxPriority, TxQueue
|
||||
from orac.state import get_channel_history, record_channel_msg
|
||||
from orac.worker import ChannelReplyWork, ChannelScreenWork, DmReplyWork, Worker
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Bot-level rate limiter — per-sender + global timers, thread-safe."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._per_key: dict[str, float] = {}
|
||||
self._global_last = 0.0
|
||||
self._reply_last: dict[str, float] = {}
|
||||
|
||||
def check(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if now - self._global_last < RATE_LIMIT_GLOBAL:
|
||||
return False
|
||||
limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER
|
||||
last = self._per_key.get(key)
|
||||
return not (last is not None and now - last < limit)
|
||||
|
||||
def reply_ok(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
last = self._reply_last.get(key)
|
||||
if last is not None and now - last < RATE_LIMIT_REPLY_COOLDOWN:
|
||||
return False
|
||||
self._reply_last[key] = now
|
||||
return True
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._per_key[key] = now
|
||||
self._global_last = now
|
||||
|
||||
|
||||
class RxRouter:
|
||||
"""Parses and dispatches every RxPacket the IOThread drains.
|
||||
|
||||
Designed to be cheap and non-blocking — never calls Claude, never sleeps.
|
||||
Reply work goes to :class:`Worker`; ACK/PATH transmissions go to
|
||||
:class:`TxQueue`; retry cancellation goes to :class:`PendingAckTable` and
|
||||
:meth:`TxQueue.cancel_by_ack`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tx_queue: TxQueue,
|
||||
worker: Worker,
|
||||
pending_acks: PendingAckTable,
|
||||
reply_cache: ReplyCache,
|
||||
rate_limiter: RateLimiter,
|
||||
metrics: Metrics,
|
||||
) -> None:
|
||||
self._tx = tx_queue
|
||||
self._worker = worker
|
||||
self._pending = pending_acks
|
||||
self._cache = reply_cache
|
||||
self._rl = rate_limiter
|
||||
self._metrics = metrics
|
||||
|
||||
# -- top-level dispatch ---------------------------------------
|
||||
|
||||
def handle(self, pkt: dict[str, Any]) -> None:
|
||||
payload_bytes: bytes = pkt.get("payload", b"")
|
||||
snr: float = float(pkt.get("snr", 0))
|
||||
|
||||
parsed = parse_header_and_path(payload_bytes)
|
||||
if parsed is None:
|
||||
return
|
||||
(
|
||||
payload_type,
|
||||
route_type,
|
||||
_payload_ver,
|
||||
_pos,
|
||||
raw_payload,
|
||||
path_hops,
|
||||
hash_size,
|
||||
) = parsed
|
||||
|
||||
# Route-learn from the incoming path
|
||||
if raw_payload and len(raw_payload) >= 2:
|
||||
src_hash: int | None = None
|
||||
if payload_type in (0x01, 0x02, 0x08):
|
||||
src_hash = raw_payload[1]
|
||||
elif payload_type == 0x04:
|
||||
src_hash = raw_payload[0]
|
||||
if src_hash is not None:
|
||||
learn_route(src_hash, path_hops, hash_size, snr=snr)
|
||||
|
||||
# Packet-hash dedup (spec Section 16)
|
||||
if is_duplicate(payload_type, raw_payload):
|
||||
self._metrics.inc("rx_dup")
|
||||
return
|
||||
|
||||
if payload_type == 0x03:
|
||||
self._handle_ack(raw_payload)
|
||||
elif payload_type == 0x0A:
|
||||
self._handle_multipart(raw_payload)
|
||||
elif payload_type == 0x04:
|
||||
self._handle_advert(raw_payload)
|
||||
elif payload_type == 0x05:
|
||||
self._handle_grp_txt(raw_payload)
|
||||
elif payload_type == 0x07:
|
||||
self._handle_anon_req(raw_payload, path_hops, hash_size)
|
||||
elif payload_type == 0x02:
|
||||
self._handle_dm(raw_payload, route_type, path_hops, hash_size, snr)
|
||||
|
||||
# -- ACK ------------------------------------------------------
|
||||
|
||||
def _handle_ack(self, raw_payload: bytes) -> None:
|
||||
"""Handle a plain PAYLOAD_TYPE_ACK (0x03): payload is 4-byte CRC."""
|
||||
if len(raw_payload) < 4:
|
||||
return
|
||||
self._consume_ack(raw_payload[:4], source="ack")
|
||||
|
||||
def _handle_multipart(self, raw_payload: bytes) -> None:
|
||||
"""Handle PAYLOAD_TYPE_MULTIPART (0x0A).
|
||||
|
||||
Upstream uses MULTIPART as a transport-level wrapper. For inner
|
||||
type == PAYLOAD_TYPE_ACK (0x03), the layout is
|
||||
``[remaining<<4 | 0x03][crc:4]`` (5 bytes). We unwrap it and treat
|
||||
the inner CRC identically to a plain ACK — which matches how the
|
||||
firmware's receiver handles it (src/Mesh.cpp:270-282).
|
||||
"""
|
||||
if len(raw_payload) < 5:
|
||||
return
|
||||
inner_type = raw_payload[0] & 0x0F
|
||||
if inner_type != 0x03:
|
||||
# MULTIPART wrappers for non-ACK inner types aren't something we
|
||||
# currently generate or consume — ignore.
|
||||
return
|
||||
self._consume_ack(raw_payload[1:5], source="multipart")
|
||||
|
||||
def _consume_ack(self, ack_crc: bytes, source: str) -> None:
|
||||
entry = self._pending.consume(ack_crc)
|
||||
if entry is None:
|
||||
return
|
||||
now = time.monotonic()
|
||||
elapsed_ms = int((now - entry.first_sent_at) * 1000)
|
||||
self._tx.cancel_by_ack(ack_crc)
|
||||
self._metrics.inc("ack_inbound_consumed")
|
||||
self._metrics.inc(f"ack_inbound_source_{source}")
|
||||
self._metrics.inc(f"reply_confirmed_attempt_{entry.attempt}")
|
||||
events.emit(
|
||||
"reply.confirmed",
|
||||
peer=entry.peer_name,
|
||||
peer_pk=entry.peer_pk.hex(),
|
||||
attempt=entry.attempt,
|
||||
elapsed_ms=elapsed_ms,
|
||||
ack_crc=ack_crc.hex(),
|
||||
source=source,
|
||||
)
|
||||
logfmt.ack_ok(entry.peer_name, entry.attempt, elapsed_ms)
|
||||
|
||||
# -- ADVERT ---------------------------------------------------
|
||||
|
||||
def _handle_advert(self, raw_payload: bytes) -> None:
|
||||
result = try_decode_advert(raw_payload)
|
||||
if result is None:
|
||||
return
|
||||
adv_pubkey, name = result
|
||||
if adv_pubkey == pubkey_bytes():
|
||||
return # our own advert echo
|
||||
register_node(adv_pubkey, name)
|
||||
logfmt.net(
|
||||
"ADVERT %s (hash=0x%02x pk=%s...)",
|
||||
name,
|
||||
adv_pubkey[0],
|
||||
adv_pubkey.hex()[:16],
|
||||
)
|
||||
if has_pending_dms(adv_pubkey[0]):
|
||||
self._process_pending_dms(adv_pubkey[0], name)
|
||||
|
||||
# -- GRP_TXT --------------------------------------------------
|
||||
|
||||
def _handle_grp_txt(self, raw_payload: bytes) -> None:
|
||||
result = try_decrypt_grp(raw_payload)
|
||||
if result is None:
|
||||
return
|
||||
channel_name, text = result
|
||||
record_channel_msg(channel_name, text)
|
||||
|
||||
if ": " in text:
|
||||
sender, _, body = text.partition(": ")
|
||||
logfmt.ch_in(channel_name, sender, body)
|
||||
else:
|
||||
sender, body = "", text
|
||||
logfmt.ch_in(channel_name, "?", body)
|
||||
if sender == BOT_NAME:
|
||||
return
|
||||
|
||||
rl_key = f"ch:{channel_name}:{sender}"
|
||||
query = extract_trigger_query(body)
|
||||
|
||||
if query is not None:
|
||||
# Explicit trigger or @mention — always process.
|
||||
logfmt.net("trigger from %s on %s: %s", sender, channel_name, query)
|
||||
self._dispatch_channel_reply(
|
||||
channel_name=channel_name,
|
||||
sender=sender,
|
||||
query=query,
|
||||
rl_key=rl_key,
|
||||
origin="trigger",
|
||||
)
|
||||
return
|
||||
|
||||
# No trigger. Only consider this message as a potential follow-up
|
||||
# if Orac has been active in this channel recently. The vast majority
|
||||
# of channel chatter hits this early return with zero API cost.
|
||||
if not followup.was_recent(channel_name, CHANNEL_FOLLOWUP_WINDOW_S):
|
||||
return
|
||||
|
||||
# Rate-limit screener calls per channel to cap cost on busy channels.
|
||||
if not followup.screener_ok(channel_name, SCREENER_PER_CHANNEL_RATE_S):
|
||||
self._metrics.inc("screener_skipped_rate")
|
||||
return
|
||||
|
||||
self._metrics.inc("screener_submitted")
|
||||
self._worker.submit(
|
||||
ChannelScreenWork(
|
||||
channel_name=channel_name,
|
||||
sender=sender,
|
||||
text=body,
|
||||
history_snapshot=get_channel_history(channel_name),
|
||||
received_at=time.monotonic(),
|
||||
rl_key=rl_key,
|
||||
)
|
||||
)
|
||||
|
||||
def _dispatch_channel_reply(
|
||||
self,
|
||||
*,
|
||||
channel_name: str,
|
||||
sender: str,
|
||||
query: str,
|
||||
rl_key: str,
|
||||
origin: str,
|
||||
) -> None:
|
||||
"""Run rate-limit gate then enqueue a ChannelReplyWork (or a rate-limit TX)."""
|
||||
if not self._rl.check(rl_key):
|
||||
log.warning("rate limited: %s on %s (%s)", sender, channel_name, origin)
|
||||
if self._rl.reply_ok(f"ch:{channel_name}"):
|
||||
msg = rate_limit_message()
|
||||
packet = build_grp_channel_packet(channel_name, BOT_NAME, msg)
|
||||
if packet is not None:
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.GRP_TXT,
|
||||
not_before=time.monotonic(),
|
||||
packet=packet,
|
||||
label=f"GRP-rate({channel_name})",
|
||||
)
|
||||
)
|
||||
self._rl.record(rl_key)
|
||||
return
|
||||
|
||||
# Mark the channel warm so screener activates for subsequent messages.
|
||||
followup.touch(channel_name)
|
||||
self._worker.submit(
|
||||
ChannelReplyWork(
|
||||
channel_name=channel_name,
|
||||
sender=sender,
|
||||
query=query,
|
||||
received_at=time.monotonic(),
|
||||
rl_key=rl_key,
|
||||
)
|
||||
)
|
||||
|
||||
# -- ANON_REQ -------------------------------------------------
|
||||
|
||||
def _handle_anon_req(
|
||||
self,
|
||||
raw_payload: bytes,
|
||||
path_hops: list[bytes],
|
||||
hash_size: int,
|
||||
) -> None:
|
||||
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)
|
||||
logfmt.net("LOGIN from %s -- sending login OK", peer_name)
|
||||
packet = build_login_response_packet(peer_pubkey)
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.LOGIN_RESP,
|
||||
not_before=time.monotonic(),
|
||||
packet=packet,
|
||||
label=f"LOGIN_RESP->{peer_name}",
|
||||
)
|
||||
)
|
||||
if has_pending_dms(peer_pubkey[0]):
|
||||
self._process_pending_dms(peer_pubkey[0], peer_name)
|
||||
|
||||
# -- DM -------------------------------------------------------
|
||||
|
||||
def _handle_dm(
|
||||
self,
|
||||
raw_payload: bytes,
|
||||
route_type: int,
|
||||
path_hops: list[bytes],
|
||||
hash_size: int,
|
||||
snr: float,
|
||||
) -> None:
|
||||
result = try_decrypt_dm(raw_payload)
|
||||
if result is None:
|
||||
# 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:
|
||||
logfmt.net(
|
||||
"queued DM from unknown 0x%02x, waiting for their ADVERT",
|
||||
src_hash,
|
||||
)
|
||||
return
|
||||
|
||||
peer_pubkey, peer_name, dm_text, raw_plaintext = result
|
||||
pk_hex = peer_pubkey.hex()
|
||||
now = time.monotonic()
|
||||
|
||||
# 1) Schedule ACK with jitter — always, regardless of hash_size or dedup.
|
||||
# Receiving and ACKing is the highest-priority guarantee.
|
||||
self._schedule_ack(raw_plaintext, peer_pubkey, now, route_type=route_type)
|
||||
|
||||
# 2) Schedule PATH return — only if inbound used >=2-byte hashes.
|
||||
# We refuse to echo 1-byte paths; if the inbound was 1-byte we skip
|
||||
# the optional path hint. The peer still gets ACKed above and will
|
||||
# still get our reply below (via flood, since learn_route also
|
||||
# rejects 1-byte — see MIN_HASH_SIZE policy).
|
||||
if hash_size >= MIN_HASH_SIZE:
|
||||
self._schedule_path_return(peer_pubkey, path_hops, hash_size, now)
|
||||
|
||||
events.emit(
|
||||
"dm.received",
|
||||
peer=peer_name,
|
||||
peer_pk=pk_hex,
|
||||
route=route_name(route_type),
|
||||
path_hops=len(path_hops),
|
||||
snr=snr,
|
||||
text_len=len(dm_text),
|
||||
)
|
||||
|
||||
# 3) Text-level dedup
|
||||
if is_dm_duplicate(pk_hex, dm_text):
|
||||
self._metrics.inc("dm_text_duplicate")
|
||||
cached = self._cache.get(pk_hex, dm_text)
|
||||
# Only treat the duplicate as loss-recovery (resend cached reply)
|
||||
# if it's close in time to the original reply; older retries are
|
||||
# assumed to be the peer legitimately re-asking.
|
||||
if cached is None or (now - cached.ts) > REPLY_CACHE_RESEND_WINDOW_S:
|
||||
if cached is not None:
|
||||
self._metrics.inc("cached_resend_skipped_stale")
|
||||
return
|
||||
# Don't pile on if the retry scheduler is still working the original.
|
||||
if self._pending.has_pending_for_peer(peer_pubkey):
|
||||
self._metrics.inc("cached_resend_skipped_pending")
|
||||
return
|
||||
jitter = random.uniform(
|
||||
ACK_JITTER_MIN_S + PATH_RETURN_DELAY_S, PATH_RETURN_DELAY_S + 0.3
|
||||
)
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.REPLY_DIRECT,
|
||||
not_before=now + jitter,
|
||||
packet=cached.reply_packet,
|
||||
label=f"DM(cached)->{peer_name}",
|
||||
expected_ack=cached.expected_ack,
|
||||
peer_pk=peer_pubkey,
|
||||
attempt=1,
|
||||
cached_reply=True,
|
||||
)
|
||||
)
|
||||
self._metrics.inc("reply_cached_resent")
|
||||
events.emit(
|
||||
"reply.cached_resend",
|
||||
peer=peer_name,
|
||||
peer_pk=pk_hex,
|
||||
ack_crc=cached.expected_ack.hex(),
|
||||
age_ms=int((now - cached.ts) * 1000),
|
||||
)
|
||||
logfmt.net(
|
||||
"duplicate DM from %s within %.0fs; re-sent cached reply",
|
||||
peer_name,
|
||||
now - cached.ts,
|
||||
)
|
||||
return
|
||||
|
||||
logfmt.dm_in(peer_name, dm_text, route=route_name(route_type))
|
||||
|
||||
# 4) Rate limit check — drops silently (ACK already queued)
|
||||
rl_key = f"dm:{pk_hex}"
|
||||
if not self._rl.check(rl_key):
|
||||
log.warning("rate limited DM: %s", peer_name)
|
||||
self._metrics.inc("dm_rate_limited")
|
||||
events.emit(
|
||||
"dm.rate_limited",
|
||||
peer=peer_name,
|
||||
peer_pk=pk_hex,
|
||||
)
|
||||
return
|
||||
|
||||
# 5) Post reply work to worker (non-blocking)
|
||||
self._worker.submit(
|
||||
DmReplyWork(
|
||||
peer_pk=peer_pubkey,
|
||||
peer_name=peer_name,
|
||||
dm_text=dm_text,
|
||||
received_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# -- pending-DM drain (run on IO thread, cheap decrypts only) ---
|
||||
|
||||
def _process_pending_dms(self, node_hash: int, peer_label: str) -> None:
|
||||
pending = pop_pending_dms(node_hash)
|
||||
if not pending:
|
||||
return
|
||||
logfmt.net("processing %d pending DM(s) from %s", len(pending), peer_label)
|
||||
for pending_payload, ts in pending:
|
||||
if is_pending_expired(ts):
|
||||
continue
|
||||
result = try_decrypt_dm(pending_payload)
|
||||
if result is None:
|
||||
continue
|
||||
peer_pubkey, peer_name, dm_text, raw_plaintext = result
|
||||
pk_hex = peer_pubkey.hex()
|
||||
now = time.monotonic()
|
||||
|
||||
# ACK always
|
||||
self._schedule_ack(raw_plaintext, peer_pubkey, now, route_type=1)
|
||||
|
||||
if is_dm_duplicate(pk_hex, dm_text):
|
||||
continue
|
||||
|
||||
logfmt.dm_in(peer_name, dm_text, route="pending")
|
||||
|
||||
rl_key = f"dm:{pk_hex}"
|
||||
if not self._rl.check(rl_key):
|
||||
log.warning("rate limited pending DM: %s", peer_name)
|
||||
continue
|
||||
|
||||
self._worker.submit(
|
||||
DmReplyWork(
|
||||
peer_pk=peer_pubkey,
|
||||
peer_name=peer_name,
|
||||
dm_text=dm_text,
|
||||
received_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# -- helpers --------------------------------------------------
|
||||
|
||||
def _schedule_ack(
|
||||
self,
|
||||
plaintext: bytes,
|
||||
peer_pubkey: bytes,
|
||||
now: float,
|
||||
route_type: int,
|
||||
) -> None:
|
||||
"""Schedule the ACK(s) for a received DM.
|
||||
|
||||
Protocol (upstream Mesh::routeDirectRecvAcks + NodePrefs.multi_acks):
|
||||
|
||||
* When :data:`MULTI_ACK_COUNT` > 0 AND we have a learned direct route
|
||||
to the peer: emit N MULTIPART ACKs with ``remaining`` counting down
|
||||
to 1, spaced ~300 ms apart, then the final plain ACK 300 ms later.
|
||||
* Otherwise: a single plain ACK with small jitter.
|
||||
|
||||
MULTIPART ACKs on flood paths are NOT protocol-correct and would just
|
||||
burn airtime (receivers key MULTIPART handling to direct-route recv).
|
||||
"""
|
||||
ack_crc = compute_ack_hash(plaintext, peer_pubkey)
|
||||
target_name = node_name(peer_pubkey.hex())
|
||||
jitter = random.uniform(ACK_JITTER_MIN_S, ACK_JITTER_MAX_S)
|
||||
|
||||
use_multipart = MULTI_ACK_COUNT > 0 and get_route(peer_pubkey[0]) is not None
|
||||
|
||||
if not use_multipart:
|
||||
packet = build_ack_packet(ack_crc, peer_pubkey[0])
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.ACK,
|
||||
not_before=now + jitter,
|
||||
packet=packet,
|
||||
label=f"ACK->0x{peer_pubkey[0]:02x}",
|
||||
)
|
||||
)
|
||||
self._metrics.inc("ack_sent")
|
||||
events.emit(
|
||||
"ack.sent",
|
||||
target_hash=peer_pubkey[0],
|
||||
target=target_name,
|
||||
ack_crc=ack_crc.hex(),
|
||||
route=route_name(route_type),
|
||||
jitter_ms=int(jitter * 1000),
|
||||
multipart=False,
|
||||
)
|
||||
return
|
||||
|
||||
# MULTIPART chain: N MULTIPART packets (remaining N, N-1, ..., 1),
|
||||
# then final plain ACK, each 300 ms + jitter after the previous.
|
||||
cumulative = jitter
|
||||
for i in range(MULTI_ACK_COUNT):
|
||||
remaining = MULTI_ACK_COUNT - i
|
||||
multi_packet = build_multiack_packet(ack_crc, remaining, peer_pubkey[0])
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.ACK,
|
||||
not_before=now + cumulative,
|
||||
packet=multi_packet,
|
||||
label=f"MULTIACK->0x{peer_pubkey[0]:02x}(r={remaining})",
|
||||
)
|
||||
)
|
||||
self._metrics.inc("multiack_sent")
|
||||
cumulative += MULTI_ACK_SPACING_S
|
||||
# Final plain ACK
|
||||
final_packet = build_ack_packet(ack_crc, peer_pubkey[0])
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.ACK,
|
||||
not_before=now + cumulative,
|
||||
packet=final_packet,
|
||||
label=f"ACK->0x{peer_pubkey[0]:02x}",
|
||||
)
|
||||
)
|
||||
self._metrics.inc("ack_sent")
|
||||
events.emit(
|
||||
"ack.sent",
|
||||
target_hash=peer_pubkey[0],
|
||||
target=target_name,
|
||||
ack_crc=ack_crc.hex(),
|
||||
route=route_name(route_type),
|
||||
jitter_ms=int(jitter * 1000),
|
||||
multipart=True,
|
||||
multipart_count=MULTI_ACK_COUNT,
|
||||
)
|
||||
|
||||
def _schedule_path_return(
|
||||
self,
|
||||
peer_pubkey: bytes,
|
||||
path_hops: list[bytes],
|
||||
hash_size: int,
|
||||
now: float,
|
||||
) -> None:
|
||||
packet = build_path_return_packet(peer_pubkey, path_hops, hash_size)
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.PATH,
|
||||
not_before=now + PATH_RETURN_DELAY_S,
|
||||
packet=packet,
|
||||
label=f"PATH->{node_name(peer_pubkey.hex())}",
|
||||
)
|
||||
)
|
||||
self._metrics.inc("path_sent")
|
||||
|
||||
|
||||
# ── Advert scheduling callback ───────────────────────────────────
|
||||
|
||||
|
||||
def make_advert_pusher(
|
||||
build_advert_fn: Callable[[], bytes],
|
||||
metrics: Metrics,
|
||||
) -> Callable[[TxQueue], None]:
|
||||
"""Return a fn that enqueues an ADVERT onto the TxQueue."""
|
||||
|
||||
def _advert_push(tx: TxQueue) -> None:
|
||||
packet = build_advert_fn()
|
||||
tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.ADVERT,
|
||||
not_before=time.monotonic(),
|
||||
packet=packet,
|
||||
label=f"ADVERT({BOT_NAME})",
|
||||
)
|
||||
)
|
||||
metrics.inc("advert_sent")
|
||||
events.emit("advert.sent", bot=BOT_NAME)
|
||||
logfmt.net("queued ADVERT as %s (hash=0x%02x)", BOT_NAME, my_hash())
|
||||
|
||||
return _advert_push
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RateLimiter",
|
||||
"RxRouter",
|
||||
"make_advert_pusher",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Structured JSONL event logger for DM reliability observability.
|
||||
|
||||
Each event is written as one JSON object per line to :data:`EVENTS_FILE`
|
||||
(default ``~/.donglora/orac-events.jsonl``). Non-blocking best-effort writes
|
||||
from any thread.
|
||||
|
||||
Use :func:`emit` to log an event with ``event`` name and arbitrary kwargs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from orac.constants import DATA_DIR, EVENTS_FILE
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
_events_lock = threading.Lock()
|
||||
_events_file: Path = EVENTS_FILE
|
||||
_initialized = False
|
||||
|
||||
|
||||
def init() -> None:
|
||||
"""Open the events file (creating parent directory if needed)."""
|
||||
global _initialized
|
||||
if _initialized:
|
||||
return
|
||||
try:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_initialized = True
|
||||
except Exception as e:
|
||||
log.warning("Events file init failed: %s", e)
|
||||
|
||||
|
||||
def set_path(path: Path) -> None:
|
||||
"""Override the events file path (used by tests)."""
|
||||
global _events_file, _initialized
|
||||
_events_file = path
|
||||
_initialized = False
|
||||
|
||||
|
||||
def emit(event: str, **fields: Any) -> None:
|
||||
"""Write one JSON event line. Never raises."""
|
||||
if not _initialized:
|
||||
init()
|
||||
rec: dict[str, Any] = {"ts": time.time(), "event": event}
|
||||
for k, v in fields.items():
|
||||
# Coerce bytes to hex for JSON
|
||||
if isinstance(v, (bytes, bytearray)):
|
||||
rec[k] = v.hex()
|
||||
else:
|
||||
rec[k] = v
|
||||
line = json.dumps(rec, default=str, separators=(",", ":"))
|
||||
try:
|
||||
with _events_lock, open(_events_file, "a") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception as e:
|
||||
# Don't let observability failures break the bot.
|
||||
log.debug("events.emit failed: %s", e)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Track recent bot activity per channel, for follow-up detection.
|
||||
|
||||
Used by :class:`orac.dispatch.RxRouter` to decide whether a non-triggered
|
||||
channel message might be a follow-up to Orac (and therefore worth screening
|
||||
with a cheap classifier) or routine chatter between other participants
|
||||
(ignore without any API call).
|
||||
|
||||
All state is in-memory and thread-safe — state is intentionally NOT persisted
|
||||
across restarts so a bot restart gives everyone a fresh conversational slate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
_lock = threading.Lock()
|
||||
_last_interaction: dict[str, float] = {}
|
||||
_last_screener: dict[str, float] = {}
|
||||
|
||||
|
||||
def touch(channel: str) -> None:
|
||||
"""Mark a channel as having just had Orac activity (trigger reply, screener
|
||||
hit, or an ADVERT from a peer we were just talking to)."""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
_last_interaction[channel] = now
|
||||
|
||||
|
||||
def was_recent(channel: str, window_s: float) -> bool:
|
||||
"""True if the bot was last active in *channel* within *window_s* seconds."""
|
||||
with _lock:
|
||||
ts = _last_interaction.get(channel)
|
||||
return ts is not None and (time.monotonic() - ts) < window_s
|
||||
|
||||
|
||||
def last_interaction_age(channel: str) -> float | None:
|
||||
"""Seconds since last bot activity in *channel*, or None if never seen."""
|
||||
with _lock:
|
||||
ts = _last_interaction.get(channel)
|
||||
return (time.monotonic() - ts) if ts is not None else None
|
||||
|
||||
|
||||
def screener_ok(channel: str, min_interval_s: float) -> bool:
|
||||
"""Per-channel rate limit for screener API calls.
|
||||
|
||||
Returns True if a screener call may fire now, and records the intent. Returns
|
||||
False if the last screener call for this channel was within ``min_interval_s``.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
last = _last_screener.get(channel)
|
||||
if last is not None and (now - last) < min_interval_s:
|
||||
return False
|
||||
_last_screener[channel] = now
|
||||
return True
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Test helper: clear all state."""
|
||||
with _lock:
|
||||
_last_interaction.clear()
|
||||
_last_screener.clear()
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
"""Structured log formatting: fixed-width colored categories for readable TTY.
|
||||
|
||||
Emit via helper functions (:func:`dm_in`, :func:`dm_out`, :func:`net`, etc.)
|
||||
so each line carries a category tag that the formatter colors and labels.
|
||||
|
||||
Environment overrides:
|
||||
- ``ORAC_LOG_LEVEL=DEBUG`` or ``ORAC_DEBUG=1`` raises verbosity to DEBUG.
|
||||
- ``NO_COLOR=1`` disables ANSI color regardless of TTY.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
|
||||
class Cat:
|
||||
"""Semantic log categories. Each maps to a tag + color in :data:`_CATS`."""
|
||||
|
||||
BOOT = "boot"
|
||||
NET = "net"
|
||||
DM_IN = "dm_in"
|
||||
DM_OUT = "dm_out"
|
||||
CH_IN = "ch_in"
|
||||
CH_OUT = "ch_out"
|
||||
ACK_OK = "ack_ok"
|
||||
RETRY = "retry"
|
||||
GONE = "gone"
|
||||
RAW = "raw"
|
||||
|
||||
|
||||
# Each entry: (5-char tag, ANSI color code). Tag is rendered inside [brackets].
|
||||
_CATS: dict[str, tuple[str, str]] = {
|
||||
Cat.BOOT: ("BOOT ", "\033[36m"), # cyan
|
||||
Cat.NET: (" NET", "\033[90m"), # bright black (dim gray)
|
||||
Cat.DM_IN: ("DM <-", "\033[1;96m"), # bold bright cyan
|
||||
Cat.DM_OUT: ("DM ->", "\033[1;95m"), # bold bright magenta
|
||||
Cat.CH_IN: ("CH <-", "\033[32m"), # green
|
||||
Cat.CH_OUT: ("CH ->", "\033[1;32m"), # bold green
|
||||
Cat.ACK_OK: ("ACK v", "\033[2;32m"), # dim green
|
||||
Cat.RETRY: ("RETRY", "\033[33m"), # yellow
|
||||
Cat.GONE: ("GONE!", "\033[31m"), # red
|
||||
Cat.RAW: (" raw ", "\033[2m"), # dim
|
||||
}
|
||||
|
||||
_LEVEL_FALLBACK: dict[int, tuple[str, str]] = {
|
||||
logging.DEBUG: (" DBG ", "\033[2m"),
|
||||
logging.INFO: ("INFO ", ""),
|
||||
logging.WARNING: ("WARN ", "\033[33m"),
|
||||
logging.ERROR: ("ERROR", "\033[31m"),
|
||||
logging.CRITICAL: (" CRIT", "\033[1;31m"),
|
||||
}
|
||||
_RESET = "\033[0m"
|
||||
|
||||
# Column widths (characters).
|
||||
_PEER_WIDTH = 14
|
||||
_CHAN_WIDTH = 12
|
||||
|
||||
|
||||
# ── Formatter ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LogFormatter(logging.Formatter):
|
||||
"""``HH:MM:SS.ms [TAG ] body`` with per-category ANSI color on TTY."""
|
||||
|
||||
def __init__(self, use_color: bool = True) -> None:
|
||||
super().__init__()
|
||||
self._use_color = use_color
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
ms = int((record.created - int(record.created)) * 1000)
|
||||
timestamp = f"{time.strftime('%H:%M:%S', time.localtime(record.created))}.{ms:03d}"
|
||||
|
||||
cat_attr: Any = getattr(record, "cat", None)
|
||||
if isinstance(cat_attr, str) and cat_attr in _CATS:
|
||||
tag, color = _CATS[cat_attr]
|
||||
else:
|
||||
tag, color = _LEVEL_FALLBACK.get(record.levelno, (" ", ""))
|
||||
|
||||
msg = record.getMessage()
|
||||
line = f"{timestamp} [{tag}] {msg}"
|
||||
if self._use_color and color:
|
||||
return f"{color}{line}{_RESET}"
|
||||
return line
|
||||
|
||||
|
||||
# ── Setup helper ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Install the orac logger with the structured formatter."""
|
||||
level_name = os.environ.get("ORAC_LOG_LEVEL", "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
if os.environ.get("ORAC_DEBUG"):
|
||||
level = logging.DEBUG
|
||||
|
||||
logger = logging.getLogger("orac")
|
||||
logger.setLevel(level)
|
||||
# Avoid stacking handlers if setup_logging is called twice.
|
||||
for h in list(logger.handlers):
|
||||
logger.removeHandler(h)
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
|
||||
handler.setFormatter(LogFormatter(use_color=use_color))
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# ── Category helpers (call sites use these) ─────────────────────
|
||||
|
||||
|
||||
def boot(msg: str, *args: Any) -> None:
|
||||
"""Startup / shutdown / lifecycle."""
|
||||
log.info(msg, *args, extra={"cat": Cat.BOOT})
|
||||
|
||||
|
||||
def net(msg: str, *args: Any) -> None:
|
||||
"""Control-plane traffic: adverts, path returns, TX wire confirmation."""
|
||||
log.info(msg, *args, extra={"cat": Cat.NET})
|
||||
|
||||
|
||||
def dm_in(peer: str, text: str, route: str | None = None) -> None:
|
||||
"""DM arrived addressed to us."""
|
||||
label = _peer_label(peer, route)
|
||||
log.info("%s %s", label, text, extra={"cat": Cat.DM_IN})
|
||||
|
||||
|
||||
def dm_out(peer: str, text: str, route: str | None = None) -> None:
|
||||
"""DM we are sending."""
|
||||
label = _peer_label(peer, route)
|
||||
log.info("%s %s", label, text, extra={"cat": Cat.DM_OUT})
|
||||
|
||||
|
||||
def ch_in(channel: str, sender: str, text: str) -> None:
|
||||
"""Channel message arrived (not necessarily to us)."""
|
||||
log.info(
|
||||
"%s %s: %s",
|
||||
_left(channel, _CHAN_WIDTH),
|
||||
sender,
|
||||
text,
|
||||
extra={"cat": Cat.CH_IN},
|
||||
)
|
||||
|
||||
|
||||
def ch_out(channel: str, text: str) -> None:
|
||||
"""Channel message we are sending."""
|
||||
log.info("%s %s", _left(channel, _CHAN_WIDTH), text, extra={"cat": Cat.CH_OUT})
|
||||
|
||||
|
||||
def ack_ok(peer: str, attempt: int, elapsed_ms: int) -> None:
|
||||
"""Inbound ACK confirms one of our outbound DMs."""
|
||||
log.info(
|
||||
"%s attempt %d, %d ms",
|
||||
_left(peer, _PEER_WIDTH),
|
||||
attempt,
|
||||
elapsed_ms,
|
||||
extra={"cat": Cat.ACK_OK},
|
||||
)
|
||||
|
||||
|
||||
def retry(peer: str, attempt: int, max_attempts: int, route: str) -> None:
|
||||
"""Reply retry fired."""
|
||||
log.info(
|
||||
"%s attempt %d/%d via %s",
|
||||
_left(peer, _PEER_WIDTH),
|
||||
attempt,
|
||||
max_attempts,
|
||||
route,
|
||||
extra={"cat": Cat.RETRY},
|
||||
)
|
||||
|
||||
|
||||
def gone(peer: str, attempts: int, reason: str = "max_attempts") -> None:
|
||||
"""Reply exhausted; peer did not ACK within our retry budget."""
|
||||
log.warning(
|
||||
"%s gave up after %d attempts (%s)",
|
||||
_left(peer, _PEER_WIDTH),
|
||||
attempts,
|
||||
reason,
|
||||
extra={"cat": Cat.GONE},
|
||||
)
|
||||
|
||||
|
||||
def raw(msg: str, *args: Any) -> None:
|
||||
"""Low-level parse / debug traffic; only visible at DEBUG level."""
|
||||
log.debug(msg, *args, extra={"cat": Cat.RAW})
|
||||
|
||||
|
||||
# ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _peer_label(peer: str, route: str | None) -> str:
|
||||
core = peer if not route else f"{peer}/{route}"
|
||||
return _left(core, _PEER_WIDTH)
|
||||
|
||||
|
||||
def _left(s: str, width: int) -> str:
|
||||
"""Pad/truncate *s* to exactly *width* columns (no trailing whitespace trim)."""
|
||||
if len(s) > width:
|
||||
return s[: width - 1] + "\u2026"
|
||||
return s.ljust(width)
|
||||
+148
-18
@@ -6,20 +6,25 @@ import hashlib
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from orac.constants import (
|
||||
DEDUP_TTL,
|
||||
DM_DEDUP_TTL,
|
||||
MIN_HASH_SIZE,
|
||||
PENDING_DM_TTL,
|
||||
RESP_SERVER_LOGIN_OK,
|
||||
ROUTE_TTL_S,
|
||||
)
|
||||
from orac.crypto import (
|
||||
build_advert_payload,
|
||||
build_peer_plaintext,
|
||||
ecdh_shared_secret,
|
||||
grp_encrypt,
|
||||
my_hash,
|
||||
peer_encrypt,
|
||||
peer_encrypt_plaintext,
|
||||
raw_peer_encrypt,
|
||||
verify_advert_signature,
|
||||
)
|
||||
@@ -27,31 +32,102 @@ from orac.crypto import (
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
# ── Route table ──────────────────────────────────────────────────
|
||||
# src_hash (int) -> (reversed_path_hops: list[bytes], hash_size: int)
|
||||
|
||||
_route_table: dict[int, tuple[list[bytes], int]] = {}
|
||||
|
||||
@dataclass
|
||||
class RouteEntry:
|
||||
"""Learned return route with TTL and SNR metadata."""
|
||||
|
||||
hops: list[bytes] # closest-relay-first after reversal of inbound path
|
||||
hash_size: int
|
||||
learned_at: float = field(default_factory=time.monotonic)
|
||||
last_snr: float | None = None
|
||||
|
||||
|
||||
_route_table: dict[int, RouteEntry] = {}
|
||||
_route_lock = threading.Lock()
|
||||
|
||||
_RT_NAMES: dict[int, str] = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
|
||||
|
||||
|
||||
def learn_route(src_hash: int, path_hops: list[bytes], hash_size: int) -> None:
|
||||
def learn_route(
|
||||
src_hash: int,
|
||||
path_hops: list[bytes],
|
||||
hash_size: int,
|
||||
snr: float | None = None,
|
||||
) -> None:
|
||||
"""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. Prefers larger hash sizes (3 > 2 > 1).
|
||||
path_hops is ordered closest-relay-first as received. We reverse it
|
||||
so our outgoing path traverses the same relays back toward the sender.
|
||||
|
||||
Upgrade rule: newer entry wins if its hash_size is not smaller AND
|
||||
its SNR is not >=3 dB worse than the existing entry's last SNR.
|
||||
|
||||
Policy: 1-byte hashes are rejected (see :data:`MIN_HASH_SIZE`). A peer
|
||||
using 1-byte path encoding simply won't get a cached route — our replies
|
||||
to them will flood instead (flood packets always use 2-byte hash mode).
|
||||
"""
|
||||
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)
|
||||
if hash_size < MIN_HASH_SIZE:
|
||||
return
|
||||
reversed_hops = list(reversed(path_hops))
|
||||
with _route_lock:
|
||||
existing = _route_table.get(src_hash)
|
||||
if existing is not None:
|
||||
if hash_size < existing.hash_size:
|
||||
return # never downgrade hash size
|
||||
if snr is not None and existing.last_snr is not None and snr < existing.last_snr - 3.0:
|
||||
# New path is ≥3 dB worse; keep current path and just refresh
|
||||
# last-seen snr for TTL purposes.
|
||||
existing.learned_at = time.monotonic()
|
||||
return
|
||||
_route_table[src_hash] = RouteEntry(
|
||||
hops=reversed_hops,
|
||||
hash_size=hash_size,
|
||||
learned_at=time.monotonic(),
|
||||
last_snr=snr,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
"""Return a learned route if it exists and hasn't expired, else None.
|
||||
|
||||
Legacy tuple return preserved so callers that only care about (hops,
|
||||
hash_size) keep working. Use :func:`get_route_entry` to access SNR.
|
||||
"""
|
||||
with _route_lock:
|
||||
entry = _route_table.get(dest_hash)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry.learned_at > ROUTE_TTL_S:
|
||||
# Expired: lazy-evict so the next learn can re-populate.
|
||||
del _route_table[dest_hash]
|
||||
return None
|
||||
return (list(entry.hops), entry.hash_size)
|
||||
|
||||
|
||||
def get_route_entry(dest_hash: int) -> RouteEntry | None:
|
||||
"""Return the full RouteEntry if live, else None."""
|
||||
with _route_lock:
|
||||
entry = _route_table.get(dest_hash)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry.learned_at > ROUTE_TTL_S:
|
||||
del _route_table[dest_hash]
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def forget_route(dest_hash: int) -> bool:
|
||||
"""Hard-delete a learned route. Returns True if an entry was removed."""
|
||||
with _route_lock:
|
||||
return _route_table.pop(dest_hash, None) is not None
|
||||
|
||||
|
||||
def route_table_size() -> int:
|
||||
"""Current live route table size (after lazy eviction — call sparingly)."""
|
||||
with _route_lock:
|
||||
return len(_route_table)
|
||||
|
||||
|
||||
def route_name(route_type: int) -> str:
|
||||
@@ -230,10 +306,40 @@ def build_advert_packet() -> bytes:
|
||||
|
||||
def build_dm_packet(peer_pubkey: bytes, text: str) -> bytes:
|
||||
"""Build a TXT_MSG DM packet using learned route or flood."""
|
||||
packet, _ = build_dm_packet_with_plaintext(peer_pubkey, text)
|
||||
return packet
|
||||
|
||||
|
||||
def build_dm_packet_with_plaintext(
|
||||
peer_pubkey: bytes,
|
||||
text: str,
|
||||
force_flood: bool = False,
|
||||
ts: int | None = None,
|
||||
attempt: int = 0,
|
||||
) -> tuple[bytes, bytes]:
|
||||
"""Build a TXT_MSG DM packet and return (packet, plaintext).
|
||||
|
||||
Plaintext is needed to precompute the expected ACK CRC that the
|
||||
recipient's firmware will emit back to us.
|
||||
|
||||
:param ts: override the plaintext timestamp; defaults to ``int(time.time())``.
|
||||
Callers that need retry-stable ACK CRCs should pass a fixed ts and
|
||||
increment ``attempt`` on each retry.
|
||||
:param attempt: value for the ``txt_type_attempt`` plaintext byte (0-63).
|
||||
:param force_flood: if True, emit a flood route-type packet regardless of
|
||||
any learned route in the table.
|
||||
"""
|
||||
plaintext = build_peer_plaintext(text, ts=ts, attempt=attempt)
|
||||
shared_secret = ecdh_shared_secret(peer_pubkey)
|
||||
mac_ct = peer_encrypt(shared_secret, text)
|
||||
mac_ct = peer_encrypt_plaintext(shared_secret, plaintext)
|
||||
dm_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
|
||||
return _build_routed_packet(0x02, dm_payload, peer_pubkey[0])
|
||||
if force_flood:
|
||||
header = bytes([(0x02 << 2) | 1]) # flood route_type = 1
|
||||
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
|
||||
packet = header + path_len + dm_payload
|
||||
else:
|
||||
packet = _build_routed_packet(0x02, dm_payload, peer_pubkey[0])
|
||||
return packet, plaintext
|
||||
|
||||
|
||||
def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes:
|
||||
@@ -241,8 +347,32 @@ def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes:
|
||||
return _build_routed_packet(0x03, ack_crc, dest_hash)
|
||||
|
||||
|
||||
def build_multiack_packet(ack_crc: bytes, remaining: int, dest_hash: int) -> bytes:
|
||||
"""Build a MULTIPART ACK (PAYLOAD_TYPE_MULTIPART = 0x0A).
|
||||
|
||||
Upstream `createMultiAck()` (src/Mesh.cpp:570-583 in commit b1ca3d1):
|
||||
the payload is 5 bytes — ``(remaining << 4) | PAYLOAD_TYPE_ACK(0x03)``
|
||||
followed by the 4-byte ACK CRC.
|
||||
|
||||
MULTIPART ACKs are emitted ONLY on direct-routed returns (see
|
||||
:func:`Mesh::routeDirectRecvAcks` upstream). Caller should verify a
|
||||
direct route exists for ``dest_hash`` and skip MULTIPART otherwise.
|
||||
"""
|
||||
if len(ack_crc) != 4:
|
||||
raise ValueError(f"ack_crc must be 4 bytes, got {len(ack_crc)}")
|
||||
if not 0 <= remaining <= 15:
|
||||
raise ValueError(f"remaining must fit a nibble (0..15), got {remaining}")
|
||||
payload = bytes([(remaining << 4) | 0x03]) + ack_crc
|
||||
return _build_routed_packet(0x0A, payload, dest_hash)
|
||||
|
||||
|
||||
def build_path_return_packet(peer_pubkey: bytes, path_hops: list[bytes], hash_size: int) -> bytes:
|
||||
"""Build a PATH return (0x08) so the sender can learn a direct route to us."""
|
||||
"""Build a PATH return (0x08) so the sender can learn a direct route to us.
|
||||
|
||||
Rejects 1-byte hash encodings (see :data:`MIN_HASH_SIZE`).
|
||||
"""
|
||||
if hash_size < MIN_HASH_SIZE:
|
||||
raise ValueError(f"refusing to build PATH_RETURN with hash_size={hash_size}")
|
||||
reversed_hops = list(reversed(path_hops))
|
||||
hash_size_code = hash_size - 1
|
||||
path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Pending-ACK tracking and reply caching for DM reliability.
|
||||
|
||||
- :class:`PendingAckTable` — outbound replies awaiting an ACK. Workers register
|
||||
on send; the RxRouter consumes on inbound ACK; the RetryScheduler walks
|
||||
expired entries to fire retries.
|
||||
|
||||
- :class:`ReplyCache` — per-peer cache of the last reply we sent. If a peer
|
||||
retransmits the same DM (their ACK to us got through but our reply did not),
|
||||
we re-send the cached packet bytes instead of re-calling Claude.
|
||||
|
||||
Both are bounded in size and time and are safe for concurrent access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from orac.constants import (
|
||||
PENDING_ACK_CAP,
|
||||
PENDING_ACK_TTL_S,
|
||||
REPLY_CACHE_CAP,
|
||||
REPLY_CACHE_TTL_S,
|
||||
)
|
||||
|
||||
# ── Pending-ACK table ───────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingReply:
|
||||
"""An outbound DM reply awaiting an ACK from its recipient.
|
||||
|
||||
Each retry *rebuilds* the packet with an incremented ``attempt`` counter
|
||||
and (for the flood retry) freshly-decided routing. Keeping ``text`` and
|
||||
``first_ts`` here lets :class:`RetryScheduler` recreate the exact plaintext
|
||||
on each retry and recompute the expected ACK CRC.
|
||||
"""
|
||||
|
||||
expected_ack: bytes # 4-byte CRC the peer will emit; may be re-keyed on retry
|
||||
peer_pk: bytes
|
||||
peer_name: str
|
||||
text: str # reply text
|
||||
first_ts: int # wall-clock timestamp used in the plaintext; stable across retries
|
||||
first_sent_at: float = field(default_factory=time.monotonic)
|
||||
attempt: int = 1 # 1 for first TX; 2 for first retry, etc.
|
||||
retry_at: float = 0.0
|
||||
max_attempts: int = 5
|
||||
|
||||
|
||||
class PendingAckTable:
|
||||
"""Thread-safe bounded table keyed on the expected ACK CRC.
|
||||
|
||||
Capacity-bounded (LRU-ish: insertion-order eviction when full). TTL-bounded
|
||||
at registration time — callers can call :meth:`expired` to find deadlines.
|
||||
"""
|
||||
|
||||
def __init__(self, cap: int = PENDING_ACK_CAP) -> None:
|
||||
self._cap = cap
|
||||
self._by_ack: dict[bytes, PendingReply] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(self, entry: PendingReply) -> None:
|
||||
"""Insert a new pending-reply entry."""
|
||||
with self._lock:
|
||||
if len(self._by_ack) >= self._cap and entry.expected_ack not in self._by_ack:
|
||||
# Evict the oldest registration (dict insertion order in CPython ≥3.7)
|
||||
oldest = next(iter(self._by_ack))
|
||||
self._by_ack.pop(oldest, None)
|
||||
self._by_ack[entry.expected_ack] = entry
|
||||
|
||||
def consume(self, ack_crc: bytes) -> PendingReply | None:
|
||||
"""Remove and return a pending entry matching *ack_crc*, or None."""
|
||||
with self._lock:
|
||||
return self._by_ack.pop(ack_crc, None)
|
||||
|
||||
def drop(self, ack_crc: bytes) -> PendingReply | None:
|
||||
"""Remove an entry without the 'matched' semantic. Same shape as consume()."""
|
||||
with self._lock:
|
||||
return self._by_ack.pop(ack_crc, None)
|
||||
|
||||
def expired(self, now: float) -> list[PendingReply]:
|
||||
"""Return entries whose retry_at ≤ now. Caller handles them.
|
||||
|
||||
Does NOT remove them — the scheduler decides whether to retry or drop.
|
||||
"""
|
||||
with self._lock:
|
||||
return [e for e in self._by_ack.values() if e.retry_at <= now]
|
||||
|
||||
def sweep_stale(self, now: float) -> list[PendingReply]:
|
||||
"""Remove and return entries older than PENDING_ACK_TTL_S."""
|
||||
cutoff = now - PENDING_ACK_TTL_S
|
||||
with self._lock:
|
||||
stale = [e for e in self._by_ack.values() if e.first_sent_at < cutoff]
|
||||
for e in stale:
|
||||
self._by_ack.pop(e.expected_ack, None)
|
||||
return stale
|
||||
|
||||
def update(
|
||||
self,
|
||||
ack_crc: bytes,
|
||||
mutator: Callable[[PendingReply], None],
|
||||
) -> PendingReply | None:
|
||||
"""Apply *mutator* to an existing entry atomically.
|
||||
|
||||
Returns the (now-mutated) entry, or None if no match.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._by_ack.get(ack_crc)
|
||||
if entry is None:
|
||||
return None
|
||||
mutator(entry)
|
||||
return entry
|
||||
|
||||
def replace_key(self, old_ack: bytes, new_ack: bytes) -> None:
|
||||
"""Re-key an entry after path-reset rebuilds the packet (and ACK CRC)."""
|
||||
with self._lock:
|
||||
entry = self._by_ack.pop(old_ack, None)
|
||||
if entry is not None:
|
||||
entry.expected_ack = new_ack
|
||||
self._by_ack[new_ack] = entry
|
||||
|
||||
def depth(self) -> int:
|
||||
"""Current entry count."""
|
||||
with self._lock:
|
||||
return len(self._by_ack)
|
||||
|
||||
def snapshot(self) -> list[PendingReply]:
|
||||
"""Return a shallow copy of current entries for debug inspection."""
|
||||
with self._lock:
|
||||
return list(self._by_ack.values())
|
||||
|
||||
def has_pending_for_peer(self, peer_pk: bytes) -> bool:
|
||||
"""True if any entry is still waiting for an ACK from *peer_pk*."""
|
||||
with self._lock:
|
||||
return any(e.peer_pk == peer_pk for e in self._by_ack.values())
|
||||
|
||||
|
||||
# ── Reply cache ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedReply:
|
||||
peer_pk: bytes
|
||||
peer_name: str
|
||||
dm_text: str # the DM plaintext we're replying TO (dedup key)
|
||||
reply_packet: bytes
|
||||
reply_text: str
|
||||
expected_ack: bytes
|
||||
ts: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
class ReplyCache:
|
||||
"""Last-reply cache per peer; retransmitted DMs get the cached reply resend.
|
||||
|
||||
Keyed by ``peer_pubkey_hex``. If the peer's next DM plaintext matches the
|
||||
stored ``dm_text``, we know the same DM is being retried and we re-send
|
||||
the original reply instead of calling Claude again.
|
||||
"""
|
||||
|
||||
def __init__(self, cap: int = REPLY_CACHE_CAP, ttl: float = REPLY_CACHE_TTL_S) -> None:
|
||||
self._cap = cap
|
||||
self._ttl = ttl
|
||||
self._entries: dict[str, _CachedReply] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def put(
|
||||
self,
|
||||
peer_pk: bytes,
|
||||
peer_name: str,
|
||||
dm_text: str,
|
||||
reply_packet: bytes,
|
||||
reply_text: str,
|
||||
expected_ack: bytes,
|
||||
) -> None:
|
||||
key = peer_pk.hex()
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if len(self._entries) >= self._cap and key not in self._entries:
|
||||
# Drop oldest by ts
|
||||
oldest = min(self._entries, key=lambda k: self._entries[k].ts)
|
||||
self._entries.pop(oldest, None)
|
||||
self._entries[key] = _CachedReply(
|
||||
peer_pk=peer_pk,
|
||||
peer_name=peer_name,
|
||||
dm_text=dm_text,
|
||||
reply_packet=reply_packet,
|
||||
reply_text=reply_text,
|
||||
expected_ack=expected_ack,
|
||||
ts=now,
|
||||
)
|
||||
|
||||
def get(self, peer_pk_hex: str, dm_text: str) -> _CachedReply | None:
|
||||
"""Return the cached reply if the DM text matches and TTL fresh."""
|
||||
with self._lock:
|
||||
entry = self._entries.get(peer_pk_hex)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry.ts > self._ttl:
|
||||
self._entries.pop(peer_pk_hex, None)
|
||||
return None
|
||||
if entry.dm_text != dm_text:
|
||||
return None
|
||||
return entry
|
||||
|
||||
def drop(self, peer_pk_hex: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(peer_pk_hex, None)
|
||||
|
||||
def depth(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._entries)
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
"""Concurrency runtime: TxQueue, RetryScheduler, IOThread, Metrics.
|
||||
|
||||
Three threads cooperate:
|
||||
|
||||
- **IOThread** — sole owner of the donglora ``conn``. Polls RX with a short
|
||||
timeout, ticks the retry scheduler, fires the ADVERT timer, and transmits
|
||||
the next ready ``TxItem`` from the priority queue.
|
||||
|
||||
- **Worker** (defined in :mod:`orac.worker`) — processes DM reply work items,
|
||||
calls Claude off the IO loop, and enqueues reply packets onto the TxQueue.
|
||||
|
||||
- **main** — setup + CLI + signal handlers only; no hot-path responsibility.
|
||||
|
||||
The TxQueue serializes all outbound packets through a single priority heap.
|
||||
The RetryScheduler drives reply retransmission against :class:`PendingAckTable`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import itertools
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
import donglora as dl
|
||||
from orac import logfmt
|
||||
from orac.constants import (
|
||||
ADVERT_INTERVAL,
|
||||
IO_POLL_TIMEOUT_S,
|
||||
PENDING_ACK_TTL_S,
|
||||
REPLY_FLOOD_FIRST_ATTEMPT,
|
||||
RETRY_SCHEDULE_S,
|
||||
RX_DRAIN_PER_TICK,
|
||||
TX_QUEUE_CAP,
|
||||
TX_RESPONSE_TIMEOUT_S,
|
||||
)
|
||||
from orac.reply_state import PendingAckTable, PendingReply
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
|
||||
# ── TX priorities ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TxPriority(IntEnum):
|
||||
"""Ordering priorities for outbound packets. Lower = higher priority."""
|
||||
|
||||
ACK = 0
|
||||
PATH = 1
|
||||
REPLY_DIRECT = 2
|
||||
REPLY_FLOOD = 3
|
||||
LOGIN_RESP = 4
|
||||
GRP_TXT = 5
|
||||
ADVERT = 6
|
||||
|
||||
|
||||
# ── TxItem ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class TxItem:
|
||||
"""A packet queued for transmission.
|
||||
|
||||
:attr:`not_before` lets us schedule jittered / delayed sends without
|
||||
blocking the IO loop. :attr:`expected_ack` ties a REPLY TX to its entry
|
||||
in :class:`PendingAckTable` so we can cancel it if the ACK arrives while
|
||||
the reply is still queued.
|
||||
"""
|
||||
|
||||
priority: int
|
||||
not_before: float
|
||||
packet: bytes
|
||||
label: str
|
||||
expected_ack: bytes | None = None
|
||||
peer_pk: bytes | None = None
|
||||
attempt: int = 1
|
||||
cached_reply: bool = False
|
||||
|
||||
|
||||
# ── TxQueue ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(order=True)
|
||||
class _HeapEntry:
|
||||
"""Min-heap ordering key. Secondary counter breaks priority/deadline ties."""
|
||||
|
||||
not_before: float
|
||||
priority: int
|
||||
seq: int
|
||||
item: TxItem = field(compare=False)
|
||||
|
||||
|
||||
class TxQueue:
|
||||
"""Thread-safe priority queue of TxItems.
|
||||
|
||||
Ordering: earliest ``not_before`` wins; ties broken by priority, then seq.
|
||||
Capacity-bounded at :data:`TX_QUEUE_CAP` — when full, the lowest-priority
|
||||
(largest priority int) oldest entry is evicted.
|
||||
"""
|
||||
|
||||
def __init__(self, cap: int = TX_QUEUE_CAP) -> None:
|
||||
self._heap: list[_HeapEntry] = []
|
||||
self._cap = cap
|
||||
self._seq = itertools.count()
|
||||
self._lock = threading.Lock()
|
||||
# Counter-based snapshot access for metrics without holding the lock
|
||||
# longer than necessary.
|
||||
|
||||
def push(self, item: TxItem) -> None:
|
||||
with self._lock:
|
||||
if len(self._heap) >= self._cap:
|
||||
victim = max(self._heap, key=lambda e: (e.priority, e.not_before))
|
||||
try:
|
||||
self._heap.remove(victim)
|
||||
heapq.heapify(self._heap)
|
||||
log.warning(
|
||||
"TxQueue full (%d); evicted %s (prio=%d)",
|
||||
self._cap,
|
||||
victim.item.label,
|
||||
victim.priority,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
entry = _HeapEntry(
|
||||
not_before=item.not_before,
|
||||
priority=item.priority,
|
||||
seq=next(self._seq),
|
||||
item=item,
|
||||
)
|
||||
heapq.heappush(self._heap, entry)
|
||||
|
||||
def pop_ready(self, now: float) -> TxItem | None:
|
||||
"""Return the highest-priority ready item, or None if nothing due."""
|
||||
with self._lock:
|
||||
if not self._heap:
|
||||
return None
|
||||
# Find the earliest-ready entry; if its not_before > now, nothing ready.
|
||||
top = self._heap[0]
|
||||
if top.not_before > now:
|
||||
return None
|
||||
# Pop it
|
||||
heapq.heappop(self._heap)
|
||||
# There may be another ready item with higher priority (lower int)
|
||||
# whose not_before is also ≤ now but was ordered later in the heap.
|
||||
# Scan the remaining heap for a better candidate to keep fairness tight.
|
||||
better_idx = -1
|
||||
best_prio = top.priority
|
||||
for i, entry in enumerate(self._heap):
|
||||
if entry.not_before <= now and entry.priority < best_prio:
|
||||
best_prio = entry.priority
|
||||
better_idx = i
|
||||
if better_idx >= 0:
|
||||
swapped = self._heap[better_idx]
|
||||
# Put `top` back; take `swapped` instead.
|
||||
self._heap[better_idx] = top
|
||||
heapq.heapify(self._heap)
|
||||
return swapped.item
|
||||
return top.item
|
||||
|
||||
def cancel_by_ack(self, ack_crc: bytes) -> int:
|
||||
"""Remove any queued TxItems whose ``expected_ack`` matches. Returns count."""
|
||||
with self._lock:
|
||||
before = len(self._heap)
|
||||
self._heap = [e for e in self._heap if e.item.expected_ack != ack_crc]
|
||||
heapq.heapify(self._heap)
|
||||
return before - len(self._heap)
|
||||
|
||||
def depth(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._heap)
|
||||
|
||||
|
||||
# ── Metrics ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Metrics:
|
||||
"""Simple threadsafe counters + gauges for SIGUSR1 dump / periodic log."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._counters: dict[str, int] = {}
|
||||
self._gauges: dict[str, int] = {}
|
||||
self.started_at = time.monotonic()
|
||||
|
||||
def inc(self, key: str, amount: int = 1) -> None:
|
||||
with self._lock:
|
||||
self._counters[key] = self._counters.get(key, 0) + amount
|
||||
|
||||
def gauge(self, key: str, value: int) -> None:
|
||||
with self._lock:
|
||||
self._gauges[key] = value
|
||||
|
||||
def snapshot(self) -> tuple[dict[str, int], dict[str, int], float]:
|
||||
with self._lock:
|
||||
return dict(self._counters), dict(self._gauges), time.monotonic() - self.started_at
|
||||
|
||||
def summary(self) -> str:
|
||||
counters, gauges, uptime_s = self.snapshot()
|
||||
parts = [f"uptime={int(uptime_s)}s"]
|
||||
for k in sorted(counters):
|
||||
parts.append(f"{k}={counters[k]}")
|
||||
for k in sorted(gauges):
|
||||
parts.append(f"{k}={gauges[k]}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# ── RetryScheduler ───────────────────────────────────────────────
|
||||
|
||||
|
||||
# Callback signature: (entry, attempt, force_flood) -> (packet, new_ack_crc)
|
||||
RebuildReplyFn = Callable[[PendingReply, int, bool], tuple[bytes, bytes]]
|
||||
|
||||
|
||||
class RetryScheduler:
|
||||
"""Drives reply retries against :class:`PendingAckTable`.
|
||||
|
||||
On :meth:`tick`, expired pending replies are either (a) rebuilt for the
|
||||
next attempt and re-queued, or (b) dropped with a WARN if exhausted.
|
||||
Attempt :data:`REPLY_FLOOD_FIRST_ATTEMPT` forgets the peer's learned route
|
||||
and rebuilds the packet with ``force_flood=True``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pending: PendingAckTable,
|
||||
tx_queue: TxQueue,
|
||||
metrics: Metrics,
|
||||
rebuild_reply: RebuildReplyFn,
|
||||
forget_route_fn: Callable[[int], bool],
|
||||
has_route_fn: Callable[[int], bool] | None = None,
|
||||
event_emit: Callable[..., None] | None = None,
|
||||
) -> None:
|
||||
self._pending = pending
|
||||
self._tx = tx_queue
|
||||
self._metrics = metrics
|
||||
self._rebuild = rebuild_reply
|
||||
self._forget = forget_route_fn
|
||||
# Optional predicate to check whether a learned route exists for a peer.
|
||||
# Used only for honest log labeling; if not provided we assume no route.
|
||||
self._has_route = has_route_fn or (lambda _h: False)
|
||||
self._emit = event_emit or _noop_emit
|
||||
|
||||
def tick(self, now: float) -> None:
|
||||
"""Fire retries/exhaustion for any expired entries."""
|
||||
# 1) Sweep very-old entries defensively
|
||||
stale = self._pending.sweep_stale(now)
|
||||
for entry in stale:
|
||||
self._metrics.inc("reply_exhausted")
|
||||
self._emit(
|
||||
"reply.exhausted",
|
||||
peer=entry.peer_name,
|
||||
peer_pk=entry.peer_pk.hex(),
|
||||
total_elapsed_ms=int((now - entry.first_sent_at) * 1000),
|
||||
reason="ttl_sweep",
|
||||
)
|
||||
logfmt.gone(entry.peer_name, entry.attempt, reason=f"stale>{int(PENDING_ACK_TTL_S)}s")
|
||||
|
||||
# 2) Fire retries for entries whose retry_at has elapsed
|
||||
for entry in self._pending.expired(now):
|
||||
if entry.attempt >= entry.max_attempts:
|
||||
self._pending.drop(entry.expected_ack)
|
||||
self._metrics.inc("reply_exhausted")
|
||||
self._emit(
|
||||
"reply.exhausted",
|
||||
peer=entry.peer_name,
|
||||
peer_pk=entry.peer_pk.hex(),
|
||||
total_elapsed_ms=int((now - entry.first_sent_at) * 1000),
|
||||
attempt=entry.attempt,
|
||||
reason="max_attempts",
|
||||
)
|
||||
logfmt.gone(entry.peer_name, entry.attempt, reason="max_attempts")
|
||||
continue
|
||||
|
||||
next_attempt = entry.attempt + 1
|
||||
force_flood = next_attempt >= REPLY_FLOOD_FIRST_ATTEMPT
|
||||
reason = "no_ack"
|
||||
|
||||
if next_attempt == REPLY_FLOOD_FIRST_ATTEMPT:
|
||||
# First flood retry: forget the learned route so the rebuild
|
||||
# actually produces a flood packet.
|
||||
removed = self._forget(entry.peer_pk[0])
|
||||
if removed:
|
||||
self._metrics.inc("route_forgot")
|
||||
self._emit(
|
||||
"route.forgot",
|
||||
peer=entry.peer_name,
|
||||
peer_pk=entry.peer_pk.hex(),
|
||||
reason="reply_retry",
|
||||
)
|
||||
reason = "path_reset"
|
||||
|
||||
try:
|
||||
packet, new_ack = self._rebuild(entry, next_attempt, force_flood)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
"Failed to rebuild reply for %s on attempt %d: %s",
|
||||
entry.peer_name,
|
||||
next_attempt,
|
||||
e,
|
||||
)
|
||||
# Drop the entry rather than retrying forever on a rebuild bug.
|
||||
self._pending.drop(entry.expected_ack)
|
||||
self._metrics.inc("reply_exhausted")
|
||||
continue
|
||||
|
||||
old_ack = entry.expected_ack
|
||||
# Index i of RETRY_SCHEDULE_S is the delay BEFORE attempt (i+2),
|
||||
# i.e., the delay from firing attempt (i+2) back to this entry.
|
||||
# After firing attempt `next_attempt`, we need the delay until
|
||||
# attempt (next_attempt+1): that's RETRY_SCHEDULE_S[next_attempt-1].
|
||||
# Clamp to the final entry if we've run out of explicit deltas.
|
||||
schedule_idx = min(next_attempt - 1, len(RETRY_SCHEDULE_S) - 1)
|
||||
retry_at = now + RETRY_SCHEDULE_S[schedule_idx]
|
||||
# Default-arg binding to freeze the loop variables into the closure.
|
||||
self._pending.update(
|
||||
old_ack,
|
||||
lambda e, _a=next_attempt, _n=new_ack, _r=retry_at: _apply_retry_update(
|
||||
e, _a, _n, _r
|
||||
),
|
||||
)
|
||||
# Re-key if the ACK CRC changed (different attempt byte → different hash)
|
||||
if new_ack != old_ack:
|
||||
self._pending.replace_key(old_ack, new_ack)
|
||||
|
||||
# Honest route label: force_flood means we definitely flooded.
|
||||
# Otherwise, the actual transmission falls back to flood if we
|
||||
# don't have a cached route — reflect that in the log.
|
||||
if force_flood:
|
||||
priority = TxPriority.REPLY_FLOOD
|
||||
route_label = "flood"
|
||||
elif self._has_route(entry.peer_pk[0]):
|
||||
priority = TxPriority.REPLY_DIRECT
|
||||
route_label = "direct"
|
||||
else:
|
||||
priority = TxPriority.REPLY_FLOOD
|
||||
route_label = "flood(no-route)"
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=priority,
|
||||
not_before=now,
|
||||
packet=packet,
|
||||
label=f"DM({route_label}, retry {next_attempt})->{entry.peer_name}",
|
||||
expected_ack=new_ack,
|
||||
peer_pk=entry.peer_pk,
|
||||
attempt=next_attempt,
|
||||
)
|
||||
)
|
||||
self._metrics.inc("reply_retried")
|
||||
self._metrics.inc(f"reply_retried_attempt_{next_attempt}")
|
||||
self._emit(
|
||||
"reply.retry",
|
||||
peer=entry.peer_name,
|
||||
peer_pk=entry.peer_pk.hex(),
|
||||
attempt=next_attempt,
|
||||
route=route_label,
|
||||
reason=reason,
|
||||
)
|
||||
logfmt.retry(entry.peer_name, next_attempt, entry.max_attempts, route_label)
|
||||
|
||||
|
||||
# ── IOThread ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class IOThread(threading.Thread):
|
||||
"""Sole owner of the donglora ``conn``; drives RX, retries, TX.
|
||||
|
||||
Runs a single-threaded event loop. Does NOT call Claude — Worker does that
|
||||
on a different thread and enqueues the resulting reply onto :class:`TxQueue`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: Any,
|
||||
tx_queue: TxQueue,
|
||||
retry_sched: RetryScheduler,
|
||||
rx_handler: Callable[[dict[str, Any]], None],
|
||||
advert_fn: Callable[[TxQueue], None],
|
||||
metrics: Metrics,
|
||||
advert_interval: float = ADVERT_INTERVAL,
|
||||
gauge_collector: Callable[[], None] | None = None,
|
||||
name: str = "orac-io",
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name=name)
|
||||
self._conn = conn
|
||||
self._tx = tx_queue
|
||||
self._retry = retry_sched
|
||||
self._rx_handler = rx_handler
|
||||
self._advert_fn = advert_fn
|
||||
self._metrics = metrics
|
||||
self._advert_interval = advert_interval
|
||||
self._gauge_collector = gauge_collector
|
||||
self._stop_event = threading.Event()
|
||||
self._last_gauge_dump = 0.0
|
||||
self.last_error: BaseException | None = None
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self._conn.timeout = IO_POLL_TIMEOUT_S
|
||||
# Advert at startup to announce ourselves; timer starts from monotonic.
|
||||
self._advert_fn(self._tx)
|
||||
last_advert = time.monotonic()
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
# 1) Drain RX — up to N per cycle
|
||||
for _ in range(RX_DRAIN_PER_TICK):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
pkt = dl.recv(self._conn)
|
||||
if pkt is None:
|
||||
break
|
||||
self._metrics.inc("rx_total")
|
||||
try:
|
||||
self._rx_handler(pkt)
|
||||
except Exception as e:
|
||||
log.exception("rx_handler error: %s", e)
|
||||
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
# 2) Tick retry scheduler
|
||||
try:
|
||||
self._retry.tick(now)
|
||||
except Exception as e:
|
||||
log.exception("retry scheduler error: %s", e)
|
||||
|
||||
# 3) Advert timer
|
||||
if now - last_advert >= self._advert_interval:
|
||||
self._advert_fn(self._tx)
|
||||
last_advert = now
|
||||
|
||||
# 4) Fire one TX item if ready
|
||||
item = self._tx.pop_ready(now)
|
||||
if item is not None:
|
||||
self._transmit(item)
|
||||
|
||||
# 5) Periodic gauge dump (every 60 s)
|
||||
if now - self._last_gauge_dump > 60.0:
|
||||
self._metrics.gauge("tx_queue_depth", self._tx.depth())
|
||||
if self._gauge_collector is not None:
|
||||
try:
|
||||
self._gauge_collector()
|
||||
except Exception:
|
||||
log.exception("gauge_collector failed")
|
||||
self._last_gauge_dump = now
|
||||
except BaseException as e: # surface fatal errors to main thread
|
||||
self.last_error = e
|
||||
log.exception("IOThread died: %s", e)
|
||||
raise
|
||||
|
||||
def _transmit(self, item: TxItem) -> None:
|
||||
"""Drive one TX via donglora.send. Does not retry on error — the retry
|
||||
scheduler already covers reply failures; ACK/PATH/ADVERT are single-shot
|
||||
by design.
|
||||
|
||||
donglora.send() reads the TxDone response using the serial port's
|
||||
timeout. Raise it around the call (LoRa airtime for a max DM is
|
||||
~425 ms + CAD) and restore after so the rest of the loop stays
|
||||
responsive at IO_POLL_TIMEOUT_S.
|
||||
"""
|
||||
old_timeout = self._conn.timeout
|
||||
self._conn.timeout = TX_RESPONSE_TIMEOUT_S
|
||||
try:
|
||||
resp = dl.send(self._conn, "Transmit", payload=item.packet)
|
||||
rtype = resp.get("type")
|
||||
if rtype == "TxDone":
|
||||
self._metrics.inc("tx_sent")
|
||||
self._metrics.inc(f"tx_{_priority_tag(item.priority)}_sent")
|
||||
logfmt.net("TX %s [%dB]", item.label, len(item.packet))
|
||||
elif rtype == "Timeout":
|
||||
self._metrics.inc("tx_error_timeout")
|
||||
log.error("TX %s: firmware timeout", item.label)
|
||||
elif rtype == "Error":
|
||||
code = resp.get("code")
|
||||
self._metrics.inc(f"tx_error_code_{code}")
|
||||
if code == 1: # RadioBusy
|
||||
self._metrics.inc("tx_radio_busy")
|
||||
log.error("TX %s: firmware error code=%s", item.label, code)
|
||||
else:
|
||||
self._metrics.inc("tx_error_other")
|
||||
log.error("TX %s: unexpected response %s", item.label, resp)
|
||||
except Exception as e:
|
||||
self._metrics.inc("tx_error_exception")
|
||||
log.exception("TX %s exception: %s", item.label, e)
|
||||
finally:
|
||||
self._conn.timeout = old_timeout
|
||||
|
||||
|
||||
def _priority_tag(prio: int) -> str:
|
||||
try:
|
||||
return TxPriority(prio).name.lower()
|
||||
except ValueError:
|
||||
return f"prio{prio}"
|
||||
|
||||
|
||||
def _noop_emit(*_args: Any, **_kwargs: Any) -> None:
|
||||
"""Default event_emit when caller doesn't provide one."""
|
||||
return None
|
||||
|
||||
|
||||
def _apply_retry_update(entry: PendingReply, attempt: int, new_ack: bytes, retry_at: float) -> None:
|
||||
entry.attempt = attempt
|
||||
entry.expected_ack = new_ack
|
||||
entry.retry_at = retry_at
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
"""Worker thread: drains DM reply work, calls Claude, builds replies, queues TX.
|
||||
|
||||
Decouples the 3-15 s Claude round trip from the RX/ACK hot path. Reply
|
||||
composition + enqueueing happens here; the IOThread handles actual transmission
|
||||
and retry firing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import queue
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from orac import events, followup, logfmt
|
||||
from orac.ai import call_claude, screen_channel_message
|
||||
from orac.constants import (
|
||||
BOT_NAME,
|
||||
MAX_DM_TEXT,
|
||||
MAX_RESPONSE_CHARS,
|
||||
REPLY_INITIAL_JITTER_MAX_S,
|
||||
REPLY_INITIAL_JITTER_MIN_S,
|
||||
REPLY_MAX_ATTEMPTS,
|
||||
RETRY_SCHEDULE_S,
|
||||
WORKER_QUEUE_CAP,
|
||||
)
|
||||
from orac.crypto import pubkey_bytes
|
||||
from orac.meshcore import (
|
||||
build_dm_packet_with_plaintext,
|
||||
build_grp_channel_packet,
|
||||
compute_ack_hash,
|
||||
get_route,
|
||||
)
|
||||
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
|
||||
from orac.runtime import Metrics, TxItem, TxPriority, TxQueue
|
||||
from orac.state import get_channel_history, get_dm_history, record_channel_msg, record_dm_msg
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
|
||||
# ── Work items ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DmReplyWork:
|
||||
"""Request to compose a reply to an inbound DM."""
|
||||
|
||||
peer_pk: bytes
|
||||
peer_name: str
|
||||
dm_text: str
|
||||
received_at: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelReplyWork:
|
||||
"""Request to compose a reply to a channel query (trigger or @mention)."""
|
||||
|
||||
channel_name: str
|
||||
sender: str
|
||||
query: str
|
||||
received_at: float
|
||||
rl_key: str # for post-send rate-limit record
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelScreenWork:
|
||||
"""Classify whether a non-triggered channel message is meant for Orac.
|
||||
|
||||
Fires a cheap Haiku screener; on YES, dispatches a :class:`ChannelReplyWork`
|
||||
inline.
|
||||
"""
|
||||
|
||||
channel_name: str
|
||||
sender: str
|
||||
text: str
|
||||
history_snapshot: list[str]
|
||||
received_at: float
|
||||
rl_key: str
|
||||
|
||||
|
||||
# Sentinel pushed by :meth:`Worker.stop`.
|
||||
class _Stop:
|
||||
pass
|
||||
|
||||
|
||||
_STOP = _Stop()
|
||||
|
||||
|
||||
# ── Worker ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Worker(threading.Thread):
|
||||
"""Single-threaded Claude-call worker.
|
||||
|
||||
Serializes Claude calls by design — the Anthropic API is thread-safe but
|
||||
the radio is the bottleneck; running multiple Claude calls in parallel
|
||||
only helps if we'd otherwise starve TX, which we don't.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tx_queue: TxQueue,
|
||||
pending_acks: PendingAckTable,
|
||||
reply_cache: ReplyCache,
|
||||
metrics: Metrics,
|
||||
on_reply_recorded: Callable[[str], None] | None = None,
|
||||
queue_cap: int = WORKER_QUEUE_CAP,
|
||||
name: str = "orac-worker",
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name=name)
|
||||
self._tx = tx_queue
|
||||
self._pending = pending_acks
|
||||
self._cache = reply_cache
|
||||
self._metrics = metrics
|
||||
self._on_recorded = on_reply_recorded
|
||||
self._queue: queue.Queue[DmReplyWork | ChannelReplyWork | ChannelScreenWork | _Stop] = (
|
||||
queue.Queue(maxsize=queue_cap)
|
||||
)
|
||||
self._stop_flag = threading.Event()
|
||||
|
||||
# -- public API -----------------------------------------------
|
||||
|
||||
def submit(self, work: DmReplyWork | ChannelReplyWork | ChannelScreenWork) -> bool:
|
||||
"""Enqueue a work item. Drops oldest if full. Returns True on accept."""
|
||||
try:
|
||||
self._queue.put_nowait(work)
|
||||
self._metrics.gauge("worker_queue_depth", self._queue.qsize())
|
||||
return True
|
||||
except queue.Full:
|
||||
# Drop oldest to make room
|
||||
try:
|
||||
dropped = self._queue.get_nowait()
|
||||
if isinstance(dropped, DmReplyWork):
|
||||
log.warning(
|
||||
"Worker queue full; dropped DM from %s: %s",
|
||||
dropped.peer_name,
|
||||
dropped.dm_text[:40],
|
||||
)
|
||||
self._metrics.inc("worker_queue_dropped_dm")
|
||||
events.emit(
|
||||
"worker.dropped",
|
||||
peer=dropped.peer_name,
|
||||
peer_pk=dropped.peer_pk.hex(),
|
||||
reason="queue_full",
|
||||
)
|
||||
elif isinstance(dropped, ChannelReplyWork):
|
||||
log.warning(
|
||||
"Worker queue full; dropped channel query on %s from %s",
|
||||
dropped.channel_name,
|
||||
dropped.sender,
|
||||
)
|
||||
self._metrics.inc("worker_queue_dropped_channel")
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(work)
|
||||
self._metrics.gauge("worker_queue_depth", self._queue.qsize())
|
||||
return True
|
||||
except queue.Full:
|
||||
log.error("Worker queue still full after eviction; rejecting")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_flag.set()
|
||||
with contextlib.suppress(queue.Full):
|
||||
self._queue.put_nowait(_STOP)
|
||||
|
||||
# -- main loop ------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
while not self._stop_flag.is_set():
|
||||
try:
|
||||
work = self._queue.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if isinstance(work, _Stop):
|
||||
return
|
||||
self._metrics.gauge("worker_queue_depth", self._queue.qsize())
|
||||
try:
|
||||
if isinstance(work, DmReplyWork):
|
||||
self._handle_dm_work(work)
|
||||
elif isinstance(work, ChannelReplyWork):
|
||||
self._handle_channel_work(work)
|
||||
elif isinstance(work, ChannelScreenWork):
|
||||
self._handle_channel_screen_work(work)
|
||||
except Exception as e:
|
||||
log.exception("Worker error: %s", e)
|
||||
|
||||
# -- reply composition ----------------------------------------
|
||||
|
||||
def _handle_dm_work(self, work: DmReplyWork) -> None:
|
||||
pk_hex = work.peer_pk.hex()
|
||||
|
||||
# Record the inbound side of the conversation before calling Claude,
|
||||
# matching legacy behavior.
|
||||
record_dm_msg(pk_hex, f"{work.peer_name}: {work.dm_text}")
|
||||
history = get_dm_history(pk_hex)
|
||||
|
||||
claude_start = time.monotonic()
|
||||
response = call_claude(
|
||||
work.dm_text,
|
||||
work.peer_name,
|
||||
history,
|
||||
MAX_DM_TEXT,
|
||||
f"DM with {work.peer_name}",
|
||||
)
|
||||
claude_ms = int((time.monotonic() - claude_start) * 1000)
|
||||
self._metrics.inc("claude_calls")
|
||||
|
||||
if response is None:
|
||||
log.warning("Claude returned no text for DM from %s", work.peer_name)
|
||||
self._metrics.inc("claude_empty")
|
||||
events.emit(
|
||||
"claude.empty",
|
||||
peer=work.peer_name,
|
||||
peer_pk=pk_hex,
|
||||
elapsed_ms=claude_ms,
|
||||
)
|
||||
return
|
||||
|
||||
# Build packet + plaintext with a stable ts we can reuse on retries.
|
||||
route_known = get_route(work.peer_pk[0]) is not None
|
||||
force_flood = not route_known
|
||||
first_ts = int(time.time())
|
||||
packet, plaintext = build_dm_packet_with_plaintext(
|
||||
work.peer_pk,
|
||||
response,
|
||||
force_flood=force_flood,
|
||||
ts=first_ts,
|
||||
attempt=0,
|
||||
)
|
||||
expected_ack = compute_ack_hash(plaintext, pubkey_bytes())
|
||||
now = time.monotonic()
|
||||
|
||||
entry = PendingReply(
|
||||
expected_ack=expected_ack,
|
||||
peer_pk=work.peer_pk,
|
||||
peer_name=work.peer_name,
|
||||
text=response,
|
||||
first_ts=first_ts,
|
||||
first_sent_at=now,
|
||||
attempt=1,
|
||||
max_attempts=REPLY_MAX_ATTEMPTS,
|
||||
retry_at=now + RETRY_SCHEDULE_S[0],
|
||||
)
|
||||
self._pending.register(entry)
|
||||
|
||||
# Cache for duplicate-DM resend
|
||||
self._cache.put(
|
||||
peer_pk=work.peer_pk,
|
||||
peer_name=work.peer_name,
|
||||
dm_text=work.dm_text,
|
||||
reply_packet=packet,
|
||||
reply_text=response,
|
||||
expected_ack=expected_ack,
|
||||
)
|
||||
|
||||
jitter = random.uniform(REPLY_INITIAL_JITTER_MIN_S, REPLY_INITIAL_JITTER_MAX_S)
|
||||
route_label = "flood" if force_flood else "direct"
|
||||
priority = TxPriority.REPLY_FLOOD if force_flood else TxPriority.REPLY_DIRECT
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=priority,
|
||||
not_before=now + jitter,
|
||||
packet=packet,
|
||||
label=f"DM({route_label})->{work.peer_name}",
|
||||
expected_ack=expected_ack,
|
||||
peer_pk=work.peer_pk,
|
||||
attempt=1,
|
||||
)
|
||||
)
|
||||
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
|
||||
self._metrics.inc("reply_sent")
|
||||
self._metrics.gauge("pending_ack_depth", self._pending.depth())
|
||||
|
||||
events.emit(
|
||||
"reply.sent",
|
||||
peer=work.peer_name,
|
||||
peer_pk=pk_hex,
|
||||
attempt=1,
|
||||
route=route_label,
|
||||
ack_crc=expected_ack.hex(),
|
||||
text_len=len(response),
|
||||
claude_ms=claude_ms,
|
||||
jitter_ms=int(jitter * 1000),
|
||||
)
|
||||
logfmt.dm_out(work.peer_name, response, route=route_label)
|
||||
|
||||
if self._on_recorded is not None:
|
||||
try:
|
||||
self._on_recorded(f"dm:{pk_hex}")
|
||||
except Exception:
|
||||
log.exception("on_reply_recorded callback raised")
|
||||
|
||||
# -- channel reply composition -------------------------------
|
||||
|
||||
def _handle_channel_work(self, work: ChannelReplyWork) -> None:
|
||||
history = get_channel_history(work.channel_name)
|
||||
claude_start = time.monotonic()
|
||||
response = call_claude(
|
||||
work.query,
|
||||
work.sender,
|
||||
history,
|
||||
MAX_RESPONSE_CHARS,
|
||||
f"channel {work.channel_name}",
|
||||
)
|
||||
claude_ms = int((time.monotonic() - claude_start) * 1000)
|
||||
self._metrics.inc("claude_calls")
|
||||
|
||||
if response is None:
|
||||
self._metrics.inc("claude_empty")
|
||||
events.emit(
|
||||
"claude.empty",
|
||||
channel=work.channel_name,
|
||||
sender=work.sender,
|
||||
elapsed_ms=claude_ms,
|
||||
)
|
||||
return
|
||||
|
||||
packet = build_grp_channel_packet(work.channel_name, BOT_NAME, response)
|
||||
if packet is None:
|
||||
log.error("Channel unknown: %s", work.channel_name)
|
||||
return
|
||||
|
||||
self._tx.push(
|
||||
TxItem(
|
||||
priority=TxPriority.GRP_TXT,
|
||||
not_before=time.monotonic(),
|
||||
packet=packet,
|
||||
label=f"GRP({work.channel_name})",
|
||||
expected_ack=None,
|
||||
peer_pk=None,
|
||||
attempt=1,
|
||||
)
|
||||
)
|
||||
record_channel_msg(work.channel_name, f"{BOT_NAME}: {response}")
|
||||
logfmt.ch_out(work.channel_name, response)
|
||||
# Mark this channel as having had bot activity so follow-ups get screened.
|
||||
followup.touch(work.channel_name)
|
||||
|
||||
events.emit(
|
||||
"channel.reply_sent",
|
||||
channel=work.channel_name,
|
||||
sender=work.sender,
|
||||
claude_ms=claude_ms,
|
||||
text_len=len(response),
|
||||
)
|
||||
|
||||
if self._on_recorded is not None:
|
||||
try:
|
||||
self._on_recorded(work.rl_key)
|
||||
except Exception:
|
||||
log.exception("on_reply_recorded callback raised")
|
||||
|
||||
# -- channel screener (cheap classifier) ---------------------
|
||||
|
||||
def _handle_channel_screen_work(self, work: ChannelScreenWork) -> None:
|
||||
verdict_start = time.monotonic()
|
||||
try:
|
||||
verdict = screen_channel_message(
|
||||
work.channel_name, work.sender, work.text, work.history_snapshot
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception("Screener call raised: %s", e)
|
||||
return
|
||||
elapsed_ms = int((time.monotonic() - verdict_start) * 1000)
|
||||
self._metrics.inc("screener_calls")
|
||||
self._metrics.inc("screener_yes" if verdict else "screener_no")
|
||||
events.emit(
|
||||
"screener.verdict",
|
||||
channel=work.channel_name,
|
||||
sender=work.sender,
|
||||
verdict=bool(verdict),
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
if not verdict:
|
||||
return
|
||||
# YES: treat as if triggered. Refresh the followup window now so
|
||||
# immediate successive messages also get screened.
|
||||
followup.touch(work.channel_name)
|
||||
logfmt.net(
|
||||
"screener: follow-up from %s on %s (%d ms) -> replying",
|
||||
work.sender,
|
||||
work.channel_name,
|
||||
elapsed_ms,
|
||||
)
|
||||
self._handle_channel_work(
|
||||
ChannelReplyWork(
|
||||
channel_name=work.channel_name,
|
||||
sender=work.sender,
|
||||
query=work.text,
|
||||
received_at=work.received_at,
|
||||
rl_key=work.rl_key,
|
||||
)
|
||||
)
|
||||
+10
-1
@@ -15,7 +15,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff"]
|
||||
dev = ["ruff", "pytest", "pyright"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["orac", "tests", "ai_bot.py"]
|
||||
pythonVersion = "3.10"
|
||||
typeCheckingMode = "standard"
|
||||
reportMissingTypeStubs = false
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,11 @@
|
||||
"""Shared pytest fixtures and path setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the project root is on sys.path so `import orac...` works from anywhere.
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
@@ -0,0 +1,42 @@
|
||||
"""ACK-hash stability across retry rebuilds.
|
||||
|
||||
The retry scheduler must be able to recompute the expected ACK CRC using the
|
||||
same plaintext (ts + attempt byte + text) that the receiver will compute against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orac.crypto import build_peer_plaintext
|
||||
from orac.meshcore import compute_ack_hash
|
||||
|
||||
|
||||
def test_ack_hash_stable_for_same_inputs() -> None:
|
||||
ts = 1700000000
|
||||
pt1 = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
pt2 = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
sender = b"\x11" * 32
|
||||
assert compute_ack_hash(pt1, sender) == compute_ack_hash(pt2, sender)
|
||||
|
||||
|
||||
def test_ack_hash_changes_with_attempt_byte() -> None:
|
||||
ts = 1700000000
|
||||
pt0 = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
pt1 = build_peer_plaintext("hello", ts=ts, attempt=1)
|
||||
sender = b"\x22" * 32
|
||||
assert compute_ack_hash(pt0, sender) != compute_ack_hash(pt1, sender)
|
||||
|
||||
|
||||
def test_ack_hash_changes_with_text() -> None:
|
||||
ts = 1700000000
|
||||
sender = b"\x33" * 32
|
||||
a = compute_ack_hash(build_peer_plaintext("hello", ts=ts, attempt=0), sender)
|
||||
b = compute_ack_hash(build_peer_plaintext("world", ts=ts, attempt=0), sender)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_ack_hash_changes_with_sender() -> None:
|
||||
ts = 1700000000
|
||||
pt = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
a = compute_ack_hash(pt, b"\x44" * 32)
|
||||
b = compute_ack_hash(pt, b"\x55" * 32)
|
||||
assert a != b
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for orac.followup: recent-activity tracker + screener rate limit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orac import followup
|
||||
|
||||
|
||||
def test_untouched_channel_is_not_recent() -> None:
|
||||
followup.reset()
|
||||
assert followup.was_recent("unused", window_s=60.0) is False
|
||||
assert followup.last_interaction_age("unused") is None
|
||||
|
||||
|
||||
def test_touch_then_was_recent_within_window() -> None:
|
||||
followup.reset()
|
||||
followup.touch("Public")
|
||||
assert followup.was_recent("Public", window_s=60.0) is True
|
||||
age = followup.last_interaction_age("Public")
|
||||
assert age is not None and age < 1.0
|
||||
|
||||
|
||||
def test_was_recent_expires_after_window() -> None:
|
||||
followup.reset()
|
||||
followup.touch("Public")
|
||||
# Immediately check with a zero-width window — should not count as recent.
|
||||
time.sleep(0.01)
|
||||
assert followup.was_recent("Public", window_s=0.001) is False
|
||||
|
||||
|
||||
def test_screener_ok_rate_limits_per_channel() -> None:
|
||||
followup.reset()
|
||||
# First call allowed
|
||||
assert followup.screener_ok("Public", min_interval_s=0.5) is True
|
||||
# Immediate second call blocked
|
||||
assert followup.screener_ok("Public", min_interval_s=0.5) is False
|
||||
# Different channel not blocked
|
||||
assert followup.screener_ok("#general", min_interval_s=0.5) is True
|
||||
|
||||
|
||||
def test_screener_ok_allows_after_interval() -> None:
|
||||
followup.reset()
|
||||
assert followup.screener_ok("Public", min_interval_s=0.05) is True
|
||||
time.sleep(0.1)
|
||||
assert followup.screener_ok("Public", min_interval_s=0.05) is True
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Protocol fidelity: MULTIPART ACK packet layout + RX unwrap.
|
||||
|
||||
Mirrors upstream MeshCore:
|
||||
- `createMultiAck(crc, remaining)` (src/Mesh.cpp:570-583)
|
||||
- `createAck(crc)` (src/Mesh.cpp:556-568)
|
||||
- Receiver unwrap (src/Mesh.cpp:270-282)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orac import meshcore
|
||||
from orac.meshcore import build_ack_packet, build_multiack_packet, parse_header_and_path
|
||||
|
||||
|
||||
def _clear_routes() -> None:
|
||||
with meshcore._route_lock:
|
||||
meshcore._route_table.clear()
|
||||
|
||||
|
||||
def test_multiack_flood_layout() -> None:
|
||||
"""With no learned route, MULTIPART uses flood route_type = 1."""
|
||||
_clear_routes()
|
||||
crc = bytes([0xDE, 0xAD, 0xBE, 0xEF])
|
||||
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x42)
|
||||
# Expect: header(1) + path_len(1) + payload(5)
|
||||
# header: PAYLOAD_TYPE_MULTIPART(0x0A) << 2 | ROUTE_TYPE_FLOOD(0x01)
|
||||
# = (0x0A << 2) | 0x01 = 0x29
|
||||
assert packet[0] == 0x29, f"header byte wrong: {packet[0]:#04x}"
|
||||
# path_len byte: 0x40 = hash_size_code=1 (2-byte), 0 hops
|
||||
assert packet[1] == 0x40
|
||||
# Payload: (remaining << 4) | 0x03 + crc
|
||||
assert packet[2] == (1 << 4) | 0x03
|
||||
assert packet[3:7] == crc
|
||||
|
||||
|
||||
def test_multiack_direct_layout_with_route() -> None:
|
||||
"""With a learned direct route, header uses ROUTE_TYPE_DIRECT = 2."""
|
||||
_clear_routes()
|
||||
# Seed a 2-byte single-hop route
|
||||
meshcore.learn_route(0x77, [b"\xaa\xbb"], hash_size=2, snr=10.0)
|
||||
crc = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
packet = build_multiack_packet(crc, remaining=2, dest_hash=0x77)
|
||||
# header = (0x0A << 2) | 0x02 (ROUTE_TYPE_DIRECT) = 0x2A
|
||||
assert packet[0] == 0x2A, f"direct-route header wrong: {packet[0]:#04x}"
|
||||
# path_len byte: hash_size_code=1 (2-byte), 1 hop -> 0x41
|
||||
assert packet[1] == 0x41
|
||||
# Path data
|
||||
assert packet[2:4] == b"\xaa\xbb"
|
||||
# Payload: (remaining << 4) | 0x03 + crc
|
||||
assert packet[4] == (2 << 4) | 0x03
|
||||
assert packet[5:9] == crc
|
||||
|
||||
|
||||
def test_multiack_remaining_nibble_bounds() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=-1, dest_hash=0x11)
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=16, dest_hash=0x11)
|
||||
# 0 and 15 are valid
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=0, dest_hash=0x11)
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=15, dest_hash=0x11)
|
||||
|
||||
|
||||
def test_multiack_crc_length_enforced() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00", remaining=1, dest_hash=0x11)
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00\x00\x00", remaining=1, dest_hash=0x11)
|
||||
|
||||
|
||||
def test_plain_ack_layout_unchanged() -> None:
|
||||
"""Regression: plain PAYLOAD_TYPE_ACK (0x03) still works as before."""
|
||||
_clear_routes()
|
||||
crc = bytes([0xAA, 0xBB, 0xCC, 0xDD])
|
||||
packet = build_ack_packet(crc, dest_hash=0x99)
|
||||
# header = (0x03 << 2) | 0x01 = 0x0D (flood)
|
||||
assert packet[0] == 0x0D
|
||||
assert packet[1] == 0x40
|
||||
# Payload is just the 4-byte CRC, no remaining-nibble byte.
|
||||
assert packet[2:6] == crc
|
||||
assert len(packet) == 6
|
||||
|
||||
|
||||
def test_round_trip_parse_extracts_multiack_payload() -> None:
|
||||
"""Firmware-style RX: parse_header_and_path strips header+path and hands us
|
||||
the 5-byte MULTIPART payload; our handler expects [nibble|type][crc:4]."""
|
||||
_clear_routes()
|
||||
crc = bytes([0xC0, 0xDE, 0xCA, 0xFE])
|
||||
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x33)
|
||||
parsed = parse_header_and_path(packet)
|
||||
assert parsed is not None
|
||||
payload_type, route_type, _ver, _pos, payload, _path_hops, _hash_size = parsed
|
||||
assert payload_type == 0x0A # PAYLOAD_TYPE_MULTIPART
|
||||
assert route_type == 0x01 # flood (no route learned)
|
||||
assert len(payload) == 5
|
||||
assert payload[0] & 0x0F == 0x03 # inner type == PAYLOAD_TYPE_ACK
|
||||
assert payload[0] >> 4 == 1 # remaining = 1
|
||||
assert payload[1:5] == crc
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for orac.reply_state.PendingAckTable and ReplyCache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
|
||||
|
||||
|
||||
def _entry(ack: bytes, peer_pk: bytes, attempt: int = 1) -> PendingReply:
|
||||
now = time.monotonic()
|
||||
return PendingReply(
|
||||
expected_ack=ack,
|
||||
peer_pk=peer_pk,
|
||||
peer_name=f"peer{peer_pk[0]:02x}",
|
||||
text="hello",
|
||||
first_ts=1234567890,
|
||||
first_sent_at=now,
|
||||
attempt=attempt,
|
||||
max_attempts=5,
|
||||
retry_at=now + 6.0,
|
||||
)
|
||||
|
||||
|
||||
def test_register_and_consume_roundtrip() -> None:
|
||||
table = PendingAckTable(cap=4)
|
||||
ack = b"\x01\x02\x03\x04"
|
||||
table.register(_entry(ack, b"\xaa" * 32))
|
||||
assert table.depth() == 1
|
||||
entry = table.consume(ack)
|
||||
assert entry is not None
|
||||
assert entry.expected_ack == ack
|
||||
assert table.depth() == 0
|
||||
# Consuming again returns None.
|
||||
assert table.consume(ack) is None
|
||||
|
||||
|
||||
def test_expired_returns_entries_past_retry_at() -> None:
|
||||
table = PendingAckTable()
|
||||
ack = b"\x00\x00\x00\x01"
|
||||
e = _entry(ack, b"\x11" * 32)
|
||||
table.register(e)
|
||||
# retry_at was set to now + 6.0 — force expiry by passing a far-future "now".
|
||||
expired = table.expired(time.monotonic() + 10.0)
|
||||
assert len(expired) == 1
|
||||
assert expired[0].expected_ack == ack
|
||||
# expired() does not remove.
|
||||
assert table.depth() == 1
|
||||
|
||||
|
||||
def test_replace_key_rekeys_entry() -> None:
|
||||
table = PendingAckTable()
|
||||
old = b"\x00\x00\x00\x01"
|
||||
new = b"\x00\x00\x00\x02"
|
||||
table.register(_entry(old, b"\x22" * 32))
|
||||
table.replace_key(old, new)
|
||||
assert table.consume(old) is None
|
||||
got = table.consume(new)
|
||||
assert got is not None and got.expected_ack == new
|
||||
|
||||
|
||||
def test_has_pending_for_peer() -> None:
|
||||
table = PendingAckTable()
|
||||
pk_a = b"\x10" + b"\x00" * 31
|
||||
pk_b = b"\x20" + b"\x00" * 31
|
||||
table.register(_entry(b"\x00\x00\x00\x01", pk_a))
|
||||
assert table.has_pending_for_peer(pk_a) is True
|
||||
assert table.has_pending_for_peer(pk_b) is False
|
||||
|
||||
|
||||
def test_cap_evicts_oldest_on_overflow() -> None:
|
||||
table = PendingAckTable(cap=2)
|
||||
table.register(_entry(b"\x01\x00\x00\x00", b"\xaa" * 32))
|
||||
table.register(_entry(b"\x02\x00\x00\x00", b"\xbb" * 32))
|
||||
table.register(_entry(b"\x03\x00\x00\x00", b"\xcc" * 32))
|
||||
# Oldest (first registered) should be gone.
|
||||
assert table.consume(b"\x01\x00\x00\x00") is None
|
||||
assert table.consume(b"\x02\x00\x00\x00") is not None
|
||||
assert table.consume(b"\x03\x00\x00\x00") is not None
|
||||
|
||||
|
||||
def test_reply_cache_roundtrip() -> None:
|
||||
cache = ReplyCache(cap=4, ttl=100.0)
|
||||
pk = b"\x55" * 32
|
||||
cache.put(
|
||||
peer_pk=pk,
|
||||
peer_name="alice",
|
||||
dm_text="hi",
|
||||
reply_packet=b"\xff\xee",
|
||||
reply_text="hello back",
|
||||
expected_ack=b"\x01\x02\x03\x04",
|
||||
)
|
||||
got = cache.get(pk.hex(), "hi")
|
||||
assert got is not None and got.reply_text == "hello back"
|
||||
# Different DM text misses.
|
||||
assert cache.get(pk.hex(), "different") is None
|
||||
|
||||
|
||||
def test_reply_cache_ttl_expires() -> None:
|
||||
cache = ReplyCache(cap=4, ttl=0.001)
|
||||
pk = b"\x66" * 32
|
||||
cache.put(
|
||||
peer_pk=pk,
|
||||
peer_name="bob",
|
||||
dm_text="q",
|
||||
reply_packet=b"\x00",
|
||||
reply_text="a",
|
||||
expected_ack=b"\x00\x00\x00\x00",
|
||||
)
|
||||
time.sleep(0.01)
|
||||
assert cache.get(pk.hex(), "q") is None
|
||||
|
||||
|
||||
def test_reply_cache_ts_lets_caller_gate_on_age() -> None:
|
||||
"""Caller can decide whether a hit is within the loss-recovery window."""
|
||||
cache = ReplyCache(cap=4, ttl=100.0)
|
||||
pk = b"\x77" * 32
|
||||
cache.put(
|
||||
peer_pk=pk,
|
||||
peer_name="carol",
|
||||
dm_text="status?",
|
||||
reply_packet=b"\x00",
|
||||
reply_text="ok",
|
||||
expected_ack=b"\x00\x00\x00\x01",
|
||||
)
|
||||
got = cache.get(pk.hex(), "status?")
|
||||
assert got is not None
|
||||
# Caller interprets .ts; entry is fresh right now.
|
||||
assert (time.monotonic() - got.ts) < 1.0
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for orac.runtime.RetryScheduler: attempt progression, path reset, exhaustion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import time
|
||||
|
||||
from orac.constants import (
|
||||
PENDING_ACK_TTL_S,
|
||||
REPLY_FLOOD_FIRST_ATTEMPT,
|
||||
REPLY_MAX_ATTEMPTS,
|
||||
RETRY_SCHEDULE_S,
|
||||
)
|
||||
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache # noqa: F401
|
||||
from orac.runtime import Metrics, RetryScheduler, TxQueue
|
||||
|
||||
|
||||
def _entry(
|
||||
ack: bytes,
|
||||
attempt: int = 1,
|
||||
retry_at_offset: float = 0.0,
|
||||
max_attempts: int = 5,
|
||||
) -> PendingReply:
|
||||
now = time.monotonic()
|
||||
return PendingReply(
|
||||
expected_ack=ack,
|
||||
peer_pk=b"\xaa" * 32,
|
||||
peer_name="peer_aa",
|
||||
text="hi",
|
||||
first_ts=1234567890,
|
||||
first_sent_at=now,
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
retry_at=now + retry_at_offset,
|
||||
)
|
||||
|
||||
|
||||
def _noop_rebuild(_entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
|
||||
"""Rebuild stub: new ACK differs from old so replace_key is exercised."""
|
||||
new_ack = bytes([attempt & 0xFF, 0xAA, 0xBB, 0xCC])
|
||||
return (b"REBUILT-%d-%s" % (attempt, b"F" if force_flood else b"D"), new_ack)
|
||||
|
||||
|
||||
def test_direct_retry_arms_next_attempt() -> None:
|
||||
pending = PendingAckTable()
|
||||
tx = TxQueue()
|
||||
m = Metrics()
|
||||
forgot: list[int] = []
|
||||
|
||||
sched = RetryScheduler(
|
||||
pending=pending,
|
||||
tx_queue=tx,
|
||||
metrics=m,
|
||||
rebuild_reply=_noop_rebuild,
|
||||
forget_route_fn=lambda h: (forgot.append(h), True)[1],
|
||||
)
|
||||
|
||||
# First attempt (entry.attempt=1) with retry_at already passed.
|
||||
e = _entry(b"\x00\x00\x00\x01", attempt=1, retry_at_offset=-0.1)
|
||||
pending.register(e)
|
||||
sched.tick(time.monotonic())
|
||||
|
||||
# Next attempt (2) is a direct retry — route should NOT have been forgotten.
|
||||
assert forgot == []
|
||||
# The entry's expected_ack has been re-keyed.
|
||||
assert pending.consume(b"\x00\x00\x00\x01") is None
|
||||
# New key from _noop_rebuild is (attempt=2, ...).
|
||||
item = tx.pop_ready(time.monotonic())
|
||||
assert item is not None
|
||||
assert item.attempt == 2
|
||||
assert item.expected_ack == bytes([0x02, 0xAA, 0xBB, 0xCC])
|
||||
|
||||
|
||||
def test_flood_retry_forgets_route() -> None:
|
||||
pending = PendingAckTable()
|
||||
tx = TxQueue()
|
||||
m = Metrics()
|
||||
forgot: list[int] = []
|
||||
sched = RetryScheduler(
|
||||
pending=pending,
|
||||
tx_queue=tx,
|
||||
metrics=m,
|
||||
rebuild_reply=_noop_rebuild,
|
||||
forget_route_fn=lambda h: (forgot.append(h), True)[1],
|
||||
)
|
||||
|
||||
# Entry attempt = REPLY_FLOOD_FIRST_ATTEMPT - 1; next tick hits flood attempt.
|
||||
e = _entry(
|
||||
b"\x00\x00\x00\x02",
|
||||
attempt=REPLY_FLOOD_FIRST_ATTEMPT - 1,
|
||||
retry_at_offset=-0.1,
|
||||
)
|
||||
pending.register(e)
|
||||
sched.tick(time.monotonic())
|
||||
|
||||
# route should have been forgotten for the peer's 1-byte hash.
|
||||
assert forgot == [e.peer_pk[0]]
|
||||
item = tx.pop_ready(time.monotonic())
|
||||
assert item is not None
|
||||
assert item.attempt == REPLY_FLOOD_FIRST_ATTEMPT
|
||||
# The rebuilt packet's label tag for flood is 'F' in our stub.
|
||||
assert item.packet.endswith(b"F")
|
||||
|
||||
|
||||
def test_exhausted_drops_and_counts() -> None:
|
||||
pending = PendingAckTable()
|
||||
tx = TxQueue()
|
||||
m = Metrics()
|
||||
sched = RetryScheduler(
|
||||
pending=pending,
|
||||
tx_queue=tx,
|
||||
metrics=m,
|
||||
rebuild_reply=_noop_rebuild,
|
||||
forget_route_fn=lambda _h: False,
|
||||
)
|
||||
|
||||
e = _entry(b"\x00\x00\x00\x03", attempt=5, retry_at_offset=-0.1, max_attempts=5)
|
||||
pending.register(e)
|
||||
sched.tick(time.monotonic())
|
||||
assert pending.depth() == 0
|
||||
counters, _, _ = m.snapshot()
|
||||
assert counters.get("reply_exhausted", 0) == 1
|
||||
|
||||
|
||||
def test_retry_schedule_is_airtime_headroom() -> None:
|
||||
# Guardrail: encourage authors to keep realistic spacing.
|
||||
assert RETRY_SCHEDULE_S[0] >= 3.0, "attempt-2 must leave headroom for a 2-hop RTT"
|
||||
for prev, cur in itertools.pairwise(RETRY_SCHEDULE_S):
|
||||
assert cur >= prev, "retry schedule should be monotonically non-decreasing"
|
||||
|
||||
|
||||
def test_schedule_covers_all_retries_for_max_attempts() -> None:
|
||||
"""Each retry after the initial send needs an explicit delay entry."""
|
||||
# REPLY_MAX_ATTEMPTS counts the initial send, so retries = MAX - 1.
|
||||
assert len(RETRY_SCHEDULE_S) >= REPLY_MAX_ATTEMPTS - 1, (
|
||||
"RETRY_SCHEDULE_S too short for REPLY_MAX_ATTEMPTS — the final retry clamps "
|
||||
"back to the last entry rather than using a distinct delay"
|
||||
)
|
||||
|
||||
|
||||
def test_pending_ack_ttl_exceeds_final_retry_plus_headroom() -> None:
|
||||
"""TTL sweep must not kill pending entries before the last retry's ACK window."""
|
||||
total_retry_delay = sum(RETRY_SCHEDULE_S[: REPLY_MAX_ATTEMPTS - 1])
|
||||
# At least 20 s past the final retry's send time for its ACK to arrive.
|
||||
min_required = total_retry_delay + 20
|
||||
assert min_required <= PENDING_ACK_TTL_S, (
|
||||
f"PENDING_ACK_TTL_S={PENDING_ACK_TTL_S} cuts the last retry short; "
|
||||
f"needs >= {min_required}s (sum of retry delays + 20s ACK window)"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests for orac.meshcore route table TTL and SNR upgrade rule."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import orac.meshcore as mc
|
||||
|
||||
|
||||
def _clear_route_table() -> None:
|
||||
with mc._route_lock:
|
||||
mc._route_table.clear()
|
||||
|
||||
|
||||
def test_learn_and_get_roundtrip() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0xAB, [b"\x11\x22"], hash_size=2, snr=10.0)
|
||||
got = mc.get_route(0xAB)
|
||||
assert got is not None
|
||||
hops, hs = got
|
||||
# path stored reversed
|
||||
assert hops == [b"\x11\x22"]
|
||||
assert hs == 2
|
||||
|
||||
|
||||
def test_ttl_expiry_and_lazy_eviction() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0xCD, [b"\x01\x02"], hash_size=2, snr=5.0)
|
||||
|
||||
# Fast-forward monotonic time by patching time.monotonic to return now+TTL+1
|
||||
fake_future = time.monotonic() + mc.ROUTE_TTL_S + 1.0
|
||||
with patch("orac.meshcore.time.monotonic", return_value=fake_future):
|
||||
assert mc.get_route(0xCD) is None
|
||||
|
||||
# Entry should have been lazy-evicted.
|
||||
assert mc.get_route(0xCD) is None
|
||||
|
||||
|
||||
def test_forget_route_removes_entry() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x55, [b"\xaa\xbb"], hash_size=2)
|
||||
assert mc.forget_route(0x55) is True
|
||||
assert mc.get_route(0x55) is None
|
||||
# Double-forget is a no-op.
|
||||
assert mc.forget_route(0x55) is False
|
||||
|
||||
|
||||
def test_snr_upgrade_rule_rejects_bad_path() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x77, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=10.0)
|
||||
# New path arrives with much worse SNR → existing path kept.
|
||||
mc.learn_route(0x77, [b"\xff\xff"], hash_size=2, snr=-5.0)
|
||||
got = mc.get_route(0x77)
|
||||
assert got is not None
|
||||
hops, _ = got
|
||||
assert hops == [b"\x03\x04", b"\x01\x02"] # original (reversed) hops
|
||||
|
||||
|
||||
def test_snr_upgrade_rule_accepts_comparable_path() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x88, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=5.0)
|
||||
# New path with slightly worse SNR (within 3 dB tolerance) → upgrade (refreshes learned_at).
|
||||
mc.learn_route(0x88, [b"\xaa\xbb"], hash_size=2, snr=3.0)
|
||||
got = mc.get_route(0x88)
|
||||
assert got is not None
|
||||
hops, _ = got
|
||||
assert hops == [b"\xaa\xbb"]
|
||||
|
||||
|
||||
def test_hash_size_downgrade_rejected() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x99, [b"\x01\x02"], hash_size=2, snr=10.0)
|
||||
# Smaller hash size should NOT replace a larger one.
|
||||
mc.learn_route(0x99, [b"\xff"], hash_size=1, snr=10.0)
|
||||
got = mc.get_route(0x99)
|
||||
assert got is not None
|
||||
_, hs = got
|
||||
assert hs == 2
|
||||
|
||||
|
||||
def test_one_byte_hash_paths_are_never_learned() -> None:
|
||||
"""Policy: we refuse to learn routes with 1-byte hashes (MIN_HASH_SIZE=2)."""
|
||||
_clear_route_table()
|
||||
mc.learn_route(0xAA, [b"\xff"], hash_size=1, snr=10.0)
|
||||
assert mc.get_route(0xAA) is None
|
||||
|
||||
|
||||
def test_build_path_return_rejects_one_byte_hashes() -> None:
|
||||
"""Policy: refuse to echo 1-byte PATH returns."""
|
||||
import pytest
|
||||
|
||||
peer_pk = b"\x55" * 32
|
||||
with pytest.raises(ValueError):
|
||||
mc.build_path_return_packet(peer_pk, [b"\xff"], hash_size=1)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for orac.runtime.TxQueue: priority ordering, not_before gating, eviction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orac.runtime import TxItem, TxPriority, TxQueue
|
||||
|
||||
|
||||
def _item(
|
||||
priority: int,
|
||||
not_before: float,
|
||||
*,
|
||||
label: str = "x",
|
||||
expected_ack: bytes | None = None,
|
||||
) -> TxItem:
|
||||
return TxItem(
|
||||
priority=priority,
|
||||
not_before=not_before,
|
||||
packet=b"\x00\x01\x02",
|
||||
label=label,
|
||||
expected_ack=expected_ack,
|
||||
)
|
||||
|
||||
|
||||
def test_pop_ready_none_when_nothing_due() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ACK, now + 10.0))
|
||||
assert q.pop_ready(now) is None
|
||||
|
||||
|
||||
def test_pop_ready_highest_priority_wins_when_all_ready() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
|
||||
q.push(_item(TxPriority.REPLY_FLOOD, now - 1.0, label="reply_flood"))
|
||||
q.push(_item(TxPriority.ACK, now - 1.0, label="ack"))
|
||||
q.push(_item(TxPriority.PATH, now - 1.0, label="path"))
|
||||
order: list[str] = []
|
||||
for _ in range(4):
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
order.append(item.label)
|
||||
assert order == ["ack", "path", "reply_flood", "advert"]
|
||||
|
||||
|
||||
def test_not_before_gates_lower_priority_first() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ACK, now + 5.0, label="future_ack"))
|
||||
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
assert item.label == "advert"
|
||||
# Future ACK still gated
|
||||
assert q.pop_ready(now) is None
|
||||
|
||||
|
||||
def test_eviction_drops_lowest_priority_oldest_when_cap() -> None:
|
||||
q = TxQueue(cap=3)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ACK, now, label="a"))
|
||||
q.push(_item(TxPriority.PATH, now, label="b"))
|
||||
q.push(_item(TxPriority.ADVERT, now, label="c"))
|
||||
# Cap hit; new ACK should evict ADVERT.
|
||||
q.push(_item(TxPriority.ACK, now, label="d"))
|
||||
labels: set[str] = set()
|
||||
for _ in range(3):
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
labels.add(item.label)
|
||||
assert labels == {"a", "b", "d"}
|
||||
|
||||
|
||||
def test_cancel_by_ack_removes_matching_items() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.REPLY_DIRECT, now, label="x", expected_ack=b"\xde\xad\xbe\xef"))
|
||||
q.push(_item(TxPriority.REPLY_DIRECT, now, label="y", expected_ack=b"\x01\x02\x03\x04"))
|
||||
q.push(_item(TxPriority.ACK, now, label="ack_bare"))
|
||||
removed = q.cancel_by_ack(b"\xde\xad\xbe\xef")
|
||||
assert removed == 1
|
||||
# The other two items survive.
|
||||
remaining: set[str] = set()
|
||||
for _ in range(2):
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
remaining.add(item.label)
|
||||
assert remaining == {"y", "ack_bare"}
|
||||
@@ -149,6 +149,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/64/390e226843cc83066acc60257e0e71ebdbce6dd915ec4f3d34101b9ac31b/cobs-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1f27b7a553d809487287e4ed88540b2c30ed9c9bb596173bfcf549e705af7bf0", size = 28205, upload-time = "2026-03-09T02:18:20.193Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
@@ -210,6 +219,8 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -224,7 +235,11 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff" }]
|
||||
dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
@@ -284,6 +299,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiter"
|
||||
version = "0.13.0"
|
||||
@@ -381,6 +405,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -558,6 +609,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pynacl"
|
||||
version = "1.6.2"
|
||||
@@ -593,6 +653,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.408"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyserial"
|
||||
version = "3.5"
|
||||
@@ -602,6 +675,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.9"
|
||||
@@ -636,6 +727,60 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in new issue
Block a user