feat!: modularize into orac/ package, add Public channel, deduplicate
Split 1424-line monolith into orac/ package with clean separation: - constants.py — bot identity, radio config, triggers, limits - crypto.py — Ed25519 identity, channel/peer crypto, ECDH - meshcore.py — packet parsing/building, routing, dedup, node registry - ai.py — Claude API integration, query extraction - state.py — JSON persistence, history tracking - bot.py — main loop, packet dispatch, connection management Removed duplicated donglora library code (cobs_frame, read_frame, encode_radio_config, encode_command, decode_response) — now uses donglora.send/recv/connect directly. Added MeshCore Public channel (fixed PSK 8b3387e9...cd72) and expanded built-in hashtag channels: #nodeops, #repeater-ops, #test, #weather, #protest, #general, #bot, #net, #emcomm. Replaced ANSI print() with stdlib logging (color formatter for TTY). Fixed dependency (donglora-python -> donglora). Strict ruff linting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11 files changed
+2350
-1417
No files matched your search
Whitespace-only changes.
+188
@@ -0,0 +1,188 @@
|
||||
"""Claude API integration: query extraction, response shortening."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
import anthropic
|
||||
|
||||
from orac.constants import (
|
||||
AT_MENTIONS,
|
||||
MAX_RESPONSE_CHARS,
|
||||
SYSTEM_PROMPT,
|
||||
TRIGGERS,
|
||||
)
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
# ── Anthropic client singleton ───────────────────────────────────
|
||||
|
||||
_client: anthropic.Anthropic | None = None
|
||||
|
||||
|
||||
def _get_client() -> anthropic.Anthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic()
|
||||
return _client
|
||||
|
||||
|
||||
# ── Response shortening ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _shorten_with_claude(text: str, max_chars: int) -> str:
|
||||
"""Use Claude to shorten text to fit within max_chars."""
|
||||
try:
|
||||
resp = _get_client().messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=256,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Shorten this to {max_chars} characters: {text}",
|
||||
}
|
||||
],
|
||||
)
|
||||
shortened: str = resp.content[0].text.strip()
|
||||
except Exception as e:
|
||||
log.error("Shorten API error: %s", e)
|
||||
shortened = text
|
||||
encoded = shortened.encode("utf-8")
|
||||
if len(encoded) > max_chars:
|
||||
while len(shortened.encode("utf-8")) > max_chars - 1:
|
||||
shortened = shortened[:-1]
|
||||
shortened += "\u2026"
|
||||
return shortened
|
||||
|
||||
|
||||
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():
|
||||
text = block.text.strip()
|
||||
return text
|
||||
|
||||
|
||||
# ── Main query function ─────────────────────────────────────────
|
||||
|
||||
|
||||
def call_claude(
|
||||
query: str,
|
||||
sender: str,
|
||||
history: list[str],
|
||||
max_chars: int,
|
||||
context_label: str,
|
||||
) -> str | None:
|
||||
"""Send a query to Claude with conversation context. Returns response text or None."""
|
||||
try:
|
||||
nonce = random.randint(1000, 9999)
|
||||
now = time.strftime("%Y-%m-%d %H:%M %Z")
|
||||
prompt = (
|
||||
SYSTEM_PROMPT.replace("{max_chars}", str(max_chars)) + f"\n\nCurrent date/time: {now}"
|
||||
)
|
||||
|
||||
if history:
|
||||
context = "\n".join(history)
|
||||
user_content = (
|
||||
f"[seed:{nonce}]\n"
|
||||
f"Recent messages in {context_label}:\n{context}\n\n"
|
||||
f"{sender} says: {query}"
|
||||
)
|
||||
else:
|
||||
user_content = f"[seed:{nonce}] {sender} says: {query}"
|
||||
|
||||
messages: list[dict[str, object]] = [{"role": "user", "content": user_content}]
|
||||
tools: list[dict[str, str]] = [{"type": "web_search_20260209", "name": "web_search"}]
|
||||
|
||||
text = ""
|
||||
for round_num in range(3):
|
||||
resp = _get_client().messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=4096,
|
||||
temperature=1.0,
|
||||
system=prompt,
|
||||
tools=tools,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
text = _extract_text(resp)
|
||||
if text:
|
||||
break
|
||||
|
||||
if resp.stop_reason != "tool_use":
|
||||
block_types = [b.type for b in resp.content]
|
||||
log.warning("No text, stop=%s, blocks=%s", resp.stop_reason, block_types)
|
||||
break
|
||||
|
||||
log.info("(web search round %d)...", round_num + 1)
|
||||
messages.append({"role": "assistant", "content": resp.content})
|
||||
tool_results: list[dict[str, str]] = []
|
||||
for block in resp.content:
|
||||
if hasattr(block, "id") and block.type == "server_tool_use":
|
||||
tool_results.append(
|
||||
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
|
||||
)
|
||||
if tool_results:
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
else:
|
||||
break
|
||||
|
||||
if not text:
|
||||
log.warning("No text after tool loop")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error("Claude API error: %s", e)
|
||||
return None
|
||||
|
||||
if len(text.encode("utf-8")) > max_chars:
|
||||
log.warning("Response too long (%dB), shortening...", len(text.encode("utf-8")))
|
||||
text = _shorten_with_claude(text, max_chars)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# ── Rate-limit message ───────────────────────────────────────────
|
||||
|
||||
|
||||
def rate_limit_message() -> str:
|
||||
"""Generate a witty rate-limit message via Claude."""
|
||||
try:
|
||||
nonce = random.randint(1000, 9999)
|
||||
resp = _get_client().messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=100,
|
||||
temperature=1.0,
|
||||
system=(
|
||||
f"You are Orac, a sardonic AI on a radio mesh network. "
|
||||
f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) "
|
||||
f"telling someone to slow down and try again in a few seconds. "
|
||||
f"Be witty, varied, and in-character. No quotes around the message."
|
||||
),
|
||||
messages=[{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}],
|
||||
)
|
||||
text: str = resp.content[0].text.strip().strip("\"'")
|
||||
if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text:
|
||||
return text
|
||||
except Exception:
|
||||
pass
|
||||
return "Patience. Try again in a moment."
|
||||
|
||||
|
||||
# ── Trigger detection ────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_trigger_query(text: str) -> str | None:
|
||||
"""Check for !triggers and @mentions. Returns query text or None."""
|
||||
lower = text.lower()
|
||||
for trigger in (*TRIGGERS, *AT_MENTIONS):
|
||||
idx = lower.find(trigger)
|
||||
if idx != -1:
|
||||
before = text[:idx]
|
||||
after = text[idx + len(trigger) :]
|
||||
query = (before + " " + after).strip()
|
||||
if query:
|
||||
return query
|
||||
return None
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
"""Main bot loop, packet dispatch, ADVERT scheduling, connection management, CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import serial
|
||||
|
||||
import donglora as dl
|
||||
from orac.ai import call_claude, extract_trigger_query, rate_limit_message
|
||||
from orac.constants import (
|
||||
ADVERT_INTERVAL,
|
||||
BOT_NAME,
|
||||
MAX_DM_TEXT,
|
||||
MAX_RESPONSE_CHARS,
|
||||
RADIO_CONFIG,
|
||||
TRIGGERS,
|
||||
)
|
||||
from orac.crypto import (
|
||||
channel_count,
|
||||
init_channels,
|
||||
init_identity,
|
||||
my_hash,
|
||||
pubkey_bytes,
|
||||
)
|
||||
from orac.meshcore import (
|
||||
build_ack_packet,
|
||||
build_advert_packet,
|
||||
build_dm_packet,
|
||||
build_grp_channel_packet,
|
||||
build_login_response_packet,
|
||||
build_path_return_packet,
|
||||
compute_ack_hash,
|
||||
get_route,
|
||||
has_pending_dms,
|
||||
is_dm_duplicate,
|
||||
is_duplicate,
|
||||
is_pending_expired,
|
||||
learn_route,
|
||||
lookup_node_by_hash,
|
||||
node_name,
|
||||
parse_header_and_path,
|
||||
pop_pending_dms,
|
||||
queue_pending_dm,
|
||||
register_node,
|
||||
route_name,
|
||||
try_decode_advert,
|
||||
try_decrypt_anon_req,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_grp,
|
||||
)
|
||||
from orac.state import (
|
||||
get_channel_history,
|
||||
get_dm_history,
|
||||
known_node_count,
|
||||
load_state,
|
||||
record_channel_msg,
|
||||
record_dm_msg,
|
||||
save_state,
|
||||
)
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
# ── Rate limiting (bot-level timers) ─────────────────────────────
|
||||
|
||||
_last_response: dict[str, float] = {}
|
||||
_last_global_response: float = 0.0
|
||||
_last_rate_limit_reply: dict[str, float] = {}
|
||||
|
||||
|
||||
def _rate_limit_check(key: str) -> bool:
|
||||
"""Check if we're allowed to respond. Returns True if OK."""
|
||||
global _last_global_response
|
||||
from orac.constants import RATE_LIMIT_DM, RATE_LIMIT_GLOBAL, RATE_LIMIT_PER_SENDER
|
||||
|
||||
now = time.monotonic()
|
||||
if now - _last_global_response < RATE_LIMIT_GLOBAL:
|
||||
return False
|
||||
limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER
|
||||
return not (key in _last_response and now - _last_response[key] < limit)
|
||||
|
||||
|
||||
def _rate_limit_reply_ok(key: str) -> bool:
|
||||
"""Check if we should send a rate-limit reply (not too often)."""
|
||||
from orac.constants import RATE_LIMIT_REPLY_COOLDOWN
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
key in _last_rate_limit_reply
|
||||
and now - _last_rate_limit_reply[key] < RATE_LIMIT_REPLY_COOLDOWN
|
||||
):
|
||||
return False
|
||||
_last_rate_limit_reply[key] = now
|
||||
return True
|
||||
|
||||
|
||||
def _rate_limit_record(key: str) -> None:
|
||||
"""Record that we just responded."""
|
||||
global _last_global_response
|
||||
now = time.monotonic()
|
||||
_last_response[key] = now
|
||||
_last_global_response = now
|
||||
|
||||
|
||||
# ── Transmit helpers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _transmit_packet(conn: Any, packet: bytes, label: str = "Transmit") -> None: # noqa: ARG001
|
||||
"""Transmit a raw MeshCore packet via the donglora library."""
|
||||
try:
|
||||
resp = dl.send(conn, "Transmit", payload=packet)
|
||||
if resp["type"] == "Timeout":
|
||||
log.error("TX failed: no response from radio")
|
||||
elif resp["type"] == "Error":
|
||||
log.error("TX failed: %s", resp)
|
||||
except Exception as e:
|
||||
log.error("TX exception: %s", e)
|
||||
|
||||
|
||||
def _send_advert(conn: Any) -> None:
|
||||
"""Transmit our ADVERT."""
|
||||
packet = build_advert_packet()
|
||||
log.info("Sending ADVERT as %s (hash=0x%02x)", BOT_NAME, my_hash())
|
||||
_transmit_packet(conn, packet, label="ADVERT")
|
||||
|
||||
|
||||
def _grp_transmit(conn: Any, channel_name: str, sender: str, text: str) -> None:
|
||||
"""Encrypt and transmit a GRP_TXT on a channel."""
|
||||
packet = build_grp_channel_packet(channel_name, sender, text)
|
||||
if packet is None:
|
||||
log.error("TX failed: unknown channel %s", channel_name)
|
||||
return
|
||||
_transmit_packet(conn, packet)
|
||||
|
||||
|
||||
def _dm_transmit(conn: Any, peer_pubkey: bytes, text: str) -> None:
|
||||
"""Encrypt and transmit a DM with simulated typing delay."""
|
||||
delay = min(max(len(text) * 0.04, 1.0), 5.0)
|
||||
time.sleep(delay)
|
||||
packet = build_dm_packet(peer_pubkey, text)
|
||||
peer = node_name(peer_pubkey.hex())
|
||||
route_str = "direct" if get_route(peer_pubkey[0]) else "flood"
|
||||
_transmit_packet(conn, packet, label=f"DM({route_str})->{peer}")
|
||||
|
||||
|
||||
def _send_ack(conn: Any, ack_crc: bytes, dest_hash: int) -> None:
|
||||
"""Send an ACK with a brief delay for the DM flood to clear."""
|
||||
time.sleep(0.5)
|
||||
packet = build_ack_packet(ack_crc, dest_hash)
|
||||
_transmit_packet(conn, packet, label="ACK")
|
||||
|
||||
|
||||
def _send_path_return(
|
||||
conn: Any, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int
|
||||
) -> None:
|
||||
"""Send a PATH return so the sender can learn a direct route to us."""
|
||||
packet = build_path_return_packet(peer_pubkey, path_hops, hash_size)
|
||||
_transmit_packet(conn, packet, label="PATH")
|
||||
|
||||
|
||||
def _send_login_response(conn: Any, peer_pubkey: bytes) -> None:
|
||||
"""Send a login OK response."""
|
||||
packet = build_login_response_packet(peer_pubkey)
|
||||
_transmit_packet(conn, packet, label=f"LOGIN_RESP->{node_name(peer_pubkey.hex())}")
|
||||
|
||||
|
||||
# ── Pending DM processing ───────────────────────────────────────
|
||||
|
||||
|
||||
def _process_pending_dms(conn: Any, node_hash: int, peer_label: str) -> None:
|
||||
"""Process queued DMs that were waiting for an ADVERT or ANON_REQ."""
|
||||
pending = pop_pending_dms(node_hash)
|
||||
if not pending:
|
||||
return
|
||||
log.info("Processing %d pending DM(s) from %s", len(pending), peer_label)
|
||||
for pending_payload, ts in pending:
|
||||
if is_pending_expired(ts):
|
||||
continue
|
||||
dm_result = try_decrypt_dm(pending_payload)
|
||||
if dm_result is None:
|
||||
continue
|
||||
peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result
|
||||
pk_hex = peer_pubkey.hex()
|
||||
_send_ack(conn, compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0])
|
||||
if is_dm_duplicate(pk_hex, dm_text):
|
||||
continue
|
||||
log.info("DM(flood) from %s: %s", peer_name, dm_text)
|
||||
record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
|
||||
history = get_dm_history(pk_hex)
|
||||
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
|
||||
if response:
|
||||
rt = "direct" if get_route(peer_pubkey[0]) else "flood"
|
||||
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
|
||||
_dm_transmit(conn, peer_pubkey, response)
|
||||
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
|
||||
_rate_limit_record(f"dm:{pk_hex}")
|
||||
|
||||
|
||||
# ── Packet dispatch ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _handle_advert(conn: Any, raw_payload: bytes) -> None:
|
||||
"""Handle an incoming ADVERT packet."""
|
||||
result = try_decode_advert(raw_payload)
|
||||
if result is None:
|
||||
return
|
||||
adv_pubkey, name = result
|
||||
if adv_pubkey == pubkey_bytes():
|
||||
return # ignore our own ADVERT echo
|
||||
register_node(adv_pubkey, name)
|
||||
log.info("ADVERT %s (hash=0x%02x pk=%s...)", name, adv_pubkey[0], adv_pubkey.hex()[:16])
|
||||
|
||||
if has_pending_dms(adv_pubkey[0]):
|
||||
_process_pending_dms(conn, adv_pubkey[0], name)
|
||||
|
||||
|
||||
def _handle_grp_txt(conn: Any, raw_payload: bytes) -> None:
|
||||
"""Handle an incoming GRP_TXT packet."""
|
||||
result = try_decrypt_grp(raw_payload)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
channel_name, text = result
|
||||
log.info("GRP_TXT %s %s", channel_name, text)
|
||||
record_channel_msg(channel_name, text)
|
||||
|
||||
if ": " not in text:
|
||||
return
|
||||
sender, _, body = text.partition(": ")
|
||||
if sender == BOT_NAME:
|
||||
return
|
||||
|
||||
query = extract_trigger_query(body)
|
||||
if query is None:
|
||||
return
|
||||
|
||||
log.info(">>> Query from %s on %s: %s", sender, channel_name, query)
|
||||
|
||||
rl_key = f"ch:{channel_name}:{sender}"
|
||||
if not _rate_limit_check(rl_key):
|
||||
log.warning("Rate limited: %s on %s", sender, channel_name)
|
||||
if _rate_limit_reply_ok(f"ch:{channel_name}"):
|
||||
msg = rate_limit_message()
|
||||
log.info("<<< %s: %s", BOT_NAME, msg)
|
||||
_grp_transmit(conn, channel_name, BOT_NAME, msg)
|
||||
_rate_limit_record(rl_key)
|
||||
return
|
||||
|
||||
history = get_channel_history(channel_name)
|
||||
response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}")
|
||||
if response is None:
|
||||
return
|
||||
|
||||
log.info("<<< %s: %s", BOT_NAME, response)
|
||||
_grp_transmit(conn, channel_name, BOT_NAME, response)
|
||||
record_channel_msg(channel_name, f"{BOT_NAME}: {response}")
|
||||
_rate_limit_record(rl_key)
|
||||
|
||||
|
||||
def _handle_anon_req(
|
||||
conn: Any,
|
||||
raw_payload: bytes,
|
||||
path_hops: list[bytes],
|
||||
hash_size: int,
|
||||
) -> None:
|
||||
"""Handle an incoming ANON_REQ (login) packet."""
|
||||
result = try_decrypt_anon_req(raw_payload)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
peer_pubkey, peer_name, _plaintext = result
|
||||
|
||||
learn_route(peer_pubkey[0], path_hops, hash_size)
|
||||
|
||||
log.info("LOGIN from %s -- sending login OK", peer_name)
|
||||
_send_login_response(conn, peer_pubkey)
|
||||
|
||||
if has_pending_dms(peer_pubkey[0]):
|
||||
_process_pending_dms(conn, peer_pubkey[0], peer_name)
|
||||
|
||||
|
||||
def _handle_txt_msg(
|
||||
conn: Any,
|
||||
raw_payload: bytes,
|
||||
route_type: int,
|
||||
path_hops: list[bytes],
|
||||
hash_size: int,
|
||||
) -> None:
|
||||
"""Handle an incoming TXT_MSG (DM) packet."""
|
||||
result = try_decrypt_dm(raw_payload)
|
||||
if result is None:
|
||||
# If addressed to us but sender unknown, queue for later
|
||||
if len(raw_payload) >= 4 and raw_payload[0] == my_hash():
|
||||
src_hash = raw_payload[1]
|
||||
if not lookup_node_by_hash(src_hash):
|
||||
is_first = queue_pending_dm(src_hash, raw_payload)
|
||||
if is_first:
|
||||
log.info("Queued DM from unknown 0x%02x, waiting for their ADVERT", src_hash)
|
||||
return
|
||||
|
||||
peer_pubkey, peer_name, dm_text, raw_plaintext = result
|
||||
|
||||
# Send ACK immediately so the sender stops retrying
|
||||
ack_crc = compute_ack_hash(raw_plaintext, peer_pubkey)
|
||||
_send_ack(conn, ack_crc, peer_pubkey[0])
|
||||
|
||||
# Send PATH return so sender can learn a direct route to us
|
||||
_send_path_return(conn, peer_pubkey, path_hops, hash_size)
|
||||
|
||||
pk_hex = peer_pubkey.hex()
|
||||
|
||||
if is_dm_duplicate(pk_hex, dm_text):
|
||||
return
|
||||
|
||||
log.info("DM(%s) from %s: %s", route_name(route_type), peer_name, dm_text)
|
||||
record_dm_msg(pk_hex, f"{peer_name}: {dm_text}")
|
||||
|
||||
rl_key = f"dm:{pk_hex}"
|
||||
if not _rate_limit_check(rl_key):
|
||||
log.warning("Rate limited DM: %s", peer_name)
|
||||
return
|
||||
|
||||
history = get_dm_history(pk_hex)
|
||||
response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}")
|
||||
if response is None:
|
||||
return
|
||||
|
||||
rt = "direct" if get_route(peer_pubkey[0]) else "flood"
|
||||
log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response)
|
||||
_dm_transmit(conn, peer_pubkey, response)
|
||||
record_dm_msg(pk_hex, f"{BOT_NAME}: {response}")
|
||||
_rate_limit_record(rl_key)
|
||||
|
||||
|
||||
# ── Bot loop ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def bot_loop(conn: Any) -> None:
|
||||
"""Main receive loop: read packets and dispatch."""
|
||||
dl.send(conn, "Ping")
|
||||
dl.send(conn, "SetConfig", config=RADIO_CONFIG)
|
||||
dl.send(conn, "StartRx")
|
||||
|
||||
_send_advert(conn)
|
||||
last_advert = time.monotonic()
|
||||
|
||||
triggers_str = ", ".join(TRIGGERS)
|
||||
log.info("%s listening (Ctrl+C to stop)", BOT_NAME)
|
||||
log.info("Channels: %d | Triggers: %s | DMs: enabled", channel_count(), triggers_str)
|
||||
conn.timeout = 1
|
||||
|
||||
while True:
|
||||
# Periodic ADVERT
|
||||
now = time.monotonic()
|
||||
if now - last_advert >= ADVERT_INTERVAL:
|
||||
_send_advert(conn)
|
||||
last_advert = now
|
||||
|
||||
pkt = dl.recv(conn)
|
||||
if pkt is None:
|
||||
continue
|
||||
|
||||
packet: bytes = pkt["payload"]
|
||||
parsed = parse_header_and_path(packet)
|
||||
if parsed is None:
|
||||
continue
|
||||
|
||||
payload_type, route_type, _payload_ver, _pos, raw_payload, path_hops, hash_size = parsed
|
||||
|
||||
# Learn return route from incoming packet's path
|
||||
if raw_payload and len(raw_payload) >= 2:
|
||||
src_hash: int | None = None
|
||||
if payload_type in (0x01, 0x02, 0x08): # RESPONSE, TXT_MSG, PATH
|
||||
src_hash = raw_payload[1]
|
||||
elif payload_type == 0x04: # ADVERT
|
||||
src_hash = raw_payload[0]
|
||||
if src_hash is not None:
|
||||
learn_route(src_hash, path_hops, hash_size)
|
||||
|
||||
# Dedup via packet hash (spec Section 16)
|
||||
if is_duplicate(payload_type, raw_payload):
|
||||
continue
|
||||
|
||||
# Dispatch by payload type
|
||||
if payload_type == 0x03: # ACK -- ignore (fire-and-forget)
|
||||
continue
|
||||
elif payload_type == 0x04: # ADVERT
|
||||
_handle_advert(conn, raw_payload)
|
||||
elif payload_type == 0x05: # GRP_TXT
|
||||
_handle_grp_txt(conn, raw_payload)
|
||||
elif payload_type == 0x07: # ANON_REQ (login)
|
||||
_handle_anon_req(conn, raw_payload, path_hops, hash_size)
|
||||
elif payload_type == 0x02: # TXT_MSG (DM)
|
||||
_handle_txt_msg(conn, raw_payload, route_type, path_hops, hash_size)
|
||||
|
||||
|
||||
# ── Logging setup ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _ColorFormatter(logging.Formatter):
|
||||
"""Log formatter that adds ANSI colors when stderr is a TTY."""
|
||||
|
||||
_COLORS: ClassVar[dict[int, str]] = {
|
||||
logging.DEBUG: "\033[2m", # dim
|
||||
logging.INFO: "\033[32m", # green
|
||||
logging.WARNING: "\033[33m", # yellow
|
||||
logging.ERROR: "\033[31m", # red
|
||||
logging.CRITICAL: "\033[1;31m", # bold red
|
||||
}
|
||||
_RESET = "\033[0m"
|
||||
|
||||
def __init__(self, use_color: bool = True) -> None:
|
||||
super().__init__(fmt=" %(message)s")
|
||||
self._use_color = use_color
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
msg = super().format(record)
|
||||
if self._use_color:
|
||||
color = self._COLORS.get(record.levelno, "")
|
||||
return f"{color}{msg}{self._RESET}"
|
||||
return msg
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Configure the orac logger with colored console output."""
|
||||
logger = logging.getLogger("orac")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(_ColorFormatter(use_color=sys.stderr.isatty()))
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# ── Entry point ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
_setup_logging()
|
||||
|
||||
if not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
log.error("ANTHROPIC_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
init_identity()
|
||||
init_channels()
|
||||
load_state()
|
||||
|
||||
log.info("%s -- MeshCore AI Bot", BOT_NAME)
|
||||
log.info(
|
||||
"Max GRP response: %d chars | Max DM response: %d chars", MAX_RESPONSE_CHARS, MAX_DM_TEXT
|
||||
)
|
||||
log.info("Channels: %d | Known nodes: %d", channel_count(), known_node_count())
|
||||
|
||||
port = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
|
||||
while True:
|
||||
try:
|
||||
log.info("Connecting...")
|
||||
conn = dl.connect(port=port, timeout=2)
|
||||
log.info("Connected")
|
||||
bot_loop(conn)
|
||||
except (serial.SerialException, ConnectionError, OSError) as e:
|
||||
log.error("Disconnected: %s", e)
|
||||
log.info("Reconnecting when device reappears...")
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
save_state()
|
||||
try:
|
||||
conn.timeout = 2 # type: ignore[possibly-undefined]
|
||||
dl.send(conn, "StopRx") # type: ignore[possibly-undefined]
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Orac bot constants: names, radio config, triggers, limits, paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# ── Bot identity ─────────────────────────────────────────────────
|
||||
|
||||
BOT_NAME: str = "Orac"
|
||||
|
||||
# ── Payload limits ───────────────────────────────────────────────
|
||||
|
||||
MAX_GRP_TEXT: int = 163 # max bytes for "SenderName: message" in a GRP_TXT
|
||||
MAX_RESPONSE_CHARS: int = MAX_GRP_TEXT - len(BOT_NAME) - 2 # subtract "Orac: "
|
||||
# DM plaintext: timestamp(4) + txt_type_attempt(1) + text + padding to 16
|
||||
# Max payload 184 - dst(1) - src(1) - mac(2) = 180 bytes ciphertext
|
||||
# 180 bytes ciphertext / 16 = 11 blocks -> 176 bytes plaintext max
|
||||
# 176 - 5 (header) = 171 bytes text, minus null terminator padding
|
||||
MAX_DM_TEXT: int = 170
|
||||
|
||||
# ── Trigger words ────────────────────────────────────────────────
|
||||
|
||||
TRIGGERS: tuple[str, ...] = ("!askai", "!ai", "!claude", "!orac")
|
||||
AT_MENTIONS: tuple[str, ...] = (f"@{BOT_NAME}".lower(), f"@[{BOT_NAME}]".lower())
|
||||
|
||||
# ── History / rate-limit sizing ──────────────────────────────────
|
||||
|
||||
CHANNEL_HISTORY_SIZE: int = 20
|
||||
DM_HISTORY_SIZE: int = 20
|
||||
|
||||
RATE_LIMIT_PER_SENDER: float = 5 # seconds, channel messages
|
||||
RATE_LIMIT_GLOBAL: float = 2 # seconds, any two responses
|
||||
RATE_LIMIT_DM: float = 2 # seconds, DMs (more interactive)
|
||||
RATE_LIMIT_REPLY_COOLDOWN: float = 30 # seconds between rate-limit replies
|
||||
|
||||
# ── Timers ───────────────────────────────────────────────────────
|
||||
|
||||
ADVERT_INTERVAL: float = 7200 # 2 hours in seconds
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────
|
||||
|
||||
DATA_DIR: Path = Path.home() / ".donglora"
|
||||
KEY_FILE: Path = DATA_DIR / "orac_key.bin"
|
||||
STATE_FILE: Path = DATA_DIR / "orac_state.json"
|
||||
|
||||
# ── Radio ────────────────────────────────────────────────────────
|
||||
|
||||
RADIO_CONFIG: dict[str, int] = {
|
||||
"freq_hz": 910_525_000,
|
||||
"bw": 6, # 62.5 kHz
|
||||
"sf": 7,
|
||||
"cr": 5, # CR 4/5
|
||||
"sync_word": 0x3444,
|
||||
"tx_power_dbm": -128, # TX_POWER_MAX
|
||||
}
|
||||
|
||||
# ── AI system prompt ─────────────────────────────────────────────
|
||||
|
||||
SYSTEM_PROMPT: str = """\
|
||||
You are Orac, a terse AI assistant on a MeshCore LoRa radio mesh network.
|
||||
|
||||
ABSOLUTE HARD LIMIT: Your ENTIRE response must be {{max_chars}} characters or fewer.
|
||||
This is a physical constraint of the radio protocol. Every character beyond this limit
|
||||
is permanently lost and never transmitted. There is NO exception.
|
||||
|
||||
Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply.
|
||||
|
||||
You can see recent messages for context. When someone asks you a question, they may
|
||||
be referring to the ongoing conversation — use the chat history to understand what they mean.
|
||||
For example, "what do you think?" refers to whatever was just discussed. "tell me more" means
|
||||
elaborate on the recent topic. Read the room.
|
||||
|
||||
Rules:
|
||||
- One sentence or short phrase. Never more.
|
||||
- Emoji OK but don't overdo it. No markdown, no bullet points, no formatting.
|
||||
- Use common abbreviations freely (e.g., w/, b/c, approx, etc).
|
||||
- Skip all pleasantries, greetings, and filler.
|
||||
- You have a dry, sardonic wit. Be helpful but never wordy.
|
||||
- If the question requires a long answer, give the most important point only.
|
||||
- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take.
|
||||
- Be creative and unpredictable. Surprise the reader.\
|
||||
"""
|
||||
|
||||
# ── MeshCore protocol constants ──────────────────────────────────
|
||||
|
||||
ADVERT_MAX_AGE: float = 43200 # 12 hours (display only, not key expiry)
|
||||
PENDING_DM_TTL: float = 300 # 5 minutes
|
||||
|
||||
DEDUP_TTL: float = 120 # seconds
|
||||
DM_DEDUP_TTL: float = 60 # seconds
|
||||
|
||||
RESP_SERVER_LOGIN_OK: int = 0x01
|
||||
|
||||
# ── 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"
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"""Identity (Ed25519 keypair), channel crypto, peer crypto, ECDH."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import struct
|
||||
import time
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from nacl.bindings import crypto_scalarmult
|
||||
from nacl.signing import SigningKey, VerifyKey
|
||||
|
||||
from orac.constants import (
|
||||
BOT_NAME,
|
||||
CHANNELS_CSV,
|
||||
DATA_DIR,
|
||||
KEY_FILE,
|
||||
MAX_GRP_TEXT,
|
||||
)
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
# ── Identity ─────────────────────────────────────────────────────
|
||||
|
||||
_signing_key: SigningKey | None = None
|
||||
_verify_key: VerifyKey | None = None
|
||||
_pubkey_bytes: bytes = b""
|
||||
|
||||
|
||||
def init_identity() -> None:
|
||||
"""Load or generate Ed25519 keypair. Persisted to disk."""
|
||||
global _signing_key, _verify_key, _pubkey_bytes
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if KEY_FILE.is_file():
|
||||
seed = KEY_FILE.read_bytes()
|
||||
if len(seed) == 32:
|
||||
_signing_key = SigningKey(seed)
|
||||
log.info("Loaded keypair from %s", KEY_FILE)
|
||||
else:
|
||||
log.warning("Invalid key file, regenerating")
|
||||
_signing_key = SigningKey.generate()
|
||||
KEY_FILE.write_bytes(bytes(_signing_key))
|
||||
else:
|
||||
_signing_key = SigningKey.generate()
|
||||
KEY_FILE.write_bytes(bytes(_signing_key))
|
||||
log.info("Generated new keypair -> %s", KEY_FILE)
|
||||
|
||||
_verify_key = _signing_key.verify_key
|
||||
_pubkey_bytes = bytes(_verify_key)
|
||||
log.info("Pubkey: %s", _pubkey_bytes.hex())
|
||||
log.info("Node hash: 0x%02x", _pubkey_bytes[0])
|
||||
|
||||
|
||||
def my_hash() -> int:
|
||||
"""Our 1-byte node hash (first byte of pubkey)."""
|
||||
return _pubkey_bytes[0]
|
||||
|
||||
|
||||
def pubkey_bytes() -> bytes:
|
||||
"""Our Ed25519 public key (32 bytes)."""
|
||||
return _pubkey_bytes
|
||||
|
||||
|
||||
def sign(message: bytes) -> bytes:
|
||||
"""Sign *message* with our identity key. Returns the 64-byte signature."""
|
||||
assert _signing_key is not None
|
||||
signed = _signing_key.sign(message)
|
||||
return signed.signature
|
||||
|
||||
|
||||
# ── Channel crypto ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def channel_secret_from_hashtag(name: str) -> bytes:
|
||||
"""Derive a 32-byte channel secret from a hashtag name."""
|
||||
h = hashlib.sha256(name.encode()).digest()
|
||||
return h[:16] + b"\x00" * 16
|
||||
|
||||
|
||||
def channel_hash(secret: bytes) -> int:
|
||||
"""Compute the 1-byte channel hash from a secret."""
|
||||
key_len = 16 if secret[16:] == b"\x00" * 16 else 32
|
||||
return hashlib.sha256(secret[:key_len]).digest()[0]
|
||||
|
||||
|
||||
def grp_verify_and_decrypt(secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None:
|
||||
"""Verify MAC and decrypt a group message. Returns plaintext or None."""
|
||||
key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14)
|
||||
computed = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
|
||||
if computed != mac_bytes:
|
||||
return None
|
||||
cipher = AES.new(secret[:16], AES.MODE_ECB)
|
||||
plaintext = b""
|
||||
for i in range(0, len(ciphertext), 16):
|
||||
plaintext += cipher.decrypt(ciphertext[i : i + 16])
|
||||
return plaintext
|
||||
|
||||
|
||||
def parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None:
|
||||
"""Parse decrypted group plaintext. Returns (timestamp, text) or None."""
|
||||
if len(plaintext) < 5:
|
||||
return None
|
||||
timestamp: int = struct.unpack_from("<I", plaintext, 0)[0]
|
||||
text = plaintext[5:].split(b"\x00", 1)[0]
|
||||
return timestamp, text.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def grp_encrypt(secret: bytes, sender: str, text: str) -> bytes:
|
||||
"""Encrypt a GRP_TXT payload. Returns channel_hash + mac + ciphertext."""
|
||||
ch = channel_hash(secret)
|
||||
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
|
||||
plaintext += f"{sender}: {text}\x00".encode()[:MAX_GRP_TEXT]
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
plaintext += b"\x00" * pad_len
|
||||
cipher = AES.new(secret[:16], AES.MODE_ECB)
|
||||
ciphertext = b""
|
||||
for i in range(0, len(plaintext), 16):
|
||||
ciphertext += cipher.encrypt(plaintext[i : i + 16])
|
||||
key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14)
|
||||
mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
|
||||
return bytes([ch]) + mac + ciphertext
|
||||
|
||||
|
||||
# ── Peer-to-peer (DM) crypto ────────────────────────────────────
|
||||
|
||||
|
||||
def ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes:
|
||||
"""Compute X25519 shared secret from our Ed25519 key and peer's Ed25519 pubkey."""
|
||||
assert _signing_key is not None
|
||||
my_x25519 = bytes(_signing_key.to_curve25519_private_key())
|
||||
peer_verify = VerifyKey(peer_ed25519_pub)
|
||||
peer_x25519 = bytes(peer_verify.to_curve25519_public_key())
|
||||
return crypto_scalarmult(my_x25519, peer_x25519)
|
||||
|
||||
|
||||
def peer_encrypt(shared_secret: bytes, text: str) -> bytes:
|
||||
"""Encrypt a DM plaintext. Returns mac(2) + ciphertext."""
|
||||
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
|
||||
plaintext += text.encode("utf-8") + b"\x00"
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
plaintext += b"\x00" * pad_len
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = b""
|
||||
for i in range(0, len(plaintext), 16):
|
||||
ciphertext += cipher.encrypt(plaintext[i : i + 16])
|
||||
mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
|
||||
return mac + ciphertext
|
||||
|
||||
|
||||
def peer_verify_and_decrypt(
|
||||
shared_secret: bytes, mac_bytes: bytes, ciphertext: bytes
|
||||
) -> bytes | None:
|
||||
"""Verify MAC then decrypt a peer message. Returns plaintext or None."""
|
||||
computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
|
||||
if computed != mac_bytes:
|
||||
return None
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
plaintext = b""
|
||||
for i in range(0, len(ciphertext), 16):
|
||||
plaintext += cipher.decrypt(ciphertext[i : i + 16])
|
||||
return plaintext
|
||||
|
||||
|
||||
def parse_peer_plaintext(plaintext: bytes) -> str | None:
|
||||
"""Parse decrypted peer message: timestamp(4) + txt_type_attempt(1) + text."""
|
||||
if len(plaintext) < 5:
|
||||
return None
|
||||
text = plaintext[5:].split(b"\x00", 1)[0]
|
||||
return text.decode("utf-8", errors="replace") if text else None
|
||||
|
||||
|
||||
def verify_advert_signature(
|
||||
pubkey: bytes, timestamp: bytes, app_data: bytes, signature: bytes
|
||||
) -> bool:
|
||||
"""Verify an ADVERT's Ed25519 signature. Returns True on success."""
|
||||
try:
|
||||
verify_key = VerifyKey(pubkey)
|
||||
sign_msg = pubkey + timestamp + app_data
|
||||
verify_key.verify(sign_msg, signature)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── Channel registry ─────────────────────────────────────────────
|
||||
|
||||
_channels: dict[str, bytes] = {}
|
||||
_channel_by_hash: dict[int, list[tuple[str, bytes]]] = {}
|
||||
|
||||
|
||||
def register_channel(name: str, secret: bytes) -> None:
|
||||
"""Register a channel name and its secret."""
|
||||
_channels[name] = secret
|
||||
h = channel_hash(secret)
|
||||
_channel_by_hash.setdefault(h, []).append((name, secret))
|
||||
|
||||
|
||||
def get_channel_secret(name: str) -> bytes | None:
|
||||
"""Get the secret for a registered channel, or None."""
|
||||
return _channels.get(name)
|
||||
|
||||
|
||||
def get_channels_by_hash(h: int) -> list[tuple[str, bytes]]:
|
||||
"""Get all channel (name, secret) pairs matching a hash byte."""
|
||||
return _channel_by_hash.get(h, [])
|
||||
|
||||
|
||||
def channel_count() -> int:
|
||||
"""Number of registered channels."""
|
||||
return len(_channels)
|
||||
|
||||
|
||||
_PUBLIC_PSK_HEX: str = "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||||
"""MeshCore Public channel pre-shared key (fixed, not hashtag-derived)."""
|
||||
|
||||
_BUILTIN_HASHTAG_CHANNELS: tuple[str, ...] = (
|
||||
# --- AI / bot trigger channels ---
|
||||
"#askai",
|
||||
"#ai",
|
||||
"#aibot",
|
||||
"#aibots",
|
||||
"#orac",
|
||||
"#bot",
|
||||
# --- Operations ---
|
||||
"#nodeops",
|
||||
"#repeater-ops",
|
||||
"#net",
|
||||
"#emcomm",
|
||||
# --- General purpose ---
|
||||
"#general",
|
||||
"#test",
|
||||
"#weather",
|
||||
"#protest",
|
||||
# --- Dev / testing ---
|
||||
"#devtest",
|
||||
"#devtestdevtest",
|
||||
)
|
||||
"""Built-in hashtag channels (key derived from SHA-256 of the name)."""
|
||||
|
||||
|
||||
def init_channels() -> None:
|
||||
"""Register the MeshCore Public channel, built-in hashtag channels, and any CSV overrides."""
|
||||
# Public channel — fixed PSK, not hashtag-derived
|
||||
register_channel("Public", bytes.fromhex(_PUBLIC_PSK_HEX) + b"\x00" * 16)
|
||||
|
||||
# Built-in hashtag channels
|
||||
for name in _BUILTIN_HASHTAG_CHANNELS:
|
||||
register_channel(name, channel_secret_from_hashtag(name))
|
||||
|
||||
# Optional CSV for additional channels (operator-defined)
|
||||
if CHANNELS_CSV.is_file():
|
||||
with open(CHANNELS_CSV, newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
name: str = row["channel_name"]
|
||||
is_hashtag = row["hashtag"].strip().lower() == "true"
|
||||
key_hex: str = row["key_hex"].strip()
|
||||
if is_hashtag:
|
||||
secret = channel_secret_from_hashtag(name)
|
||||
else:
|
||||
secret = bytes.fromhex(key_hex) + b"\x00" * 16
|
||||
register_channel(name, secret)
|
||||
|
||||
|
||||
def aes_ecb_encrypt(key: bytes, plaintext: bytes) -> bytes:
|
||||
"""AES-ECB encrypt (16-byte aligned plaintext). Used for PATH and RESPONSE."""
|
||||
cipher = AES.new(key[:16], AES.MODE_ECB)
|
||||
ciphertext = b""
|
||||
for i in range(0, len(plaintext), 16):
|
||||
ciphertext += cipher.encrypt(plaintext[i : i + 16])
|
||||
return ciphertext
|
||||
|
||||
|
||||
def compute_mac(secret: bytes, ciphertext: bytes) -> bytes:
|
||||
"""Compute 2-byte HMAC-SHA256 MAC over ciphertext."""
|
||||
return hmac.new(secret[:32], ciphertext, hashlib.sha256).digest()[:2]
|
||||
|
||||
|
||||
def build_advert_payload() -> bytes:
|
||||
"""Build the inner ADVERT payload: pubkey + timestamp + signature + app_data."""
|
||||
timestamp = struct.pack("<I", int(time.time()))
|
||||
app_data = bytes([0x81]) + BOT_NAME.encode("utf-8")
|
||||
sign_msg = _pubkey_bytes + timestamp + app_data
|
||||
signature = sign(sign_msg)
|
||||
return _pubkey_bytes + timestamp + signature + app_data
|
||||
|
||||
|
||||
def raw_peer_encrypt(shared_secret: bytes, plaintext: bytes) -> bytes:
|
||||
"""Encrypt raw plaintext (not text message) for a peer. Returns mac(2) + ciphertext."""
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
padded = plaintext + b"\x00" * pad_len
|
||||
ciphertext = aes_ecb_encrypt(shared_secret[:16], padded)
|
||||
mac = compute_mac(shared_secret, ciphertext)
|
||||
return mac + ciphertext
|
||||
|
||||
|
||||
# Silence unused-import linter for `Any` — used only in type annotations
|
||||
__all__: list[str] = [
|
||||
"aes_ecb_encrypt",
|
||||
"build_advert_payload",
|
||||
"channel_count",
|
||||
"channel_hash",
|
||||
"channel_secret_from_hashtag",
|
||||
"compute_mac",
|
||||
"ecdh_shared_secret",
|
||||
"get_channel_secret",
|
||||
"get_channels_by_hash",
|
||||
"grp_encrypt",
|
||||
"grp_verify_and_decrypt",
|
||||
"init_channels",
|
||||
"init_identity",
|
||||
"my_hash",
|
||||
"parse_grp_plaintext",
|
||||
"parse_peer_plaintext",
|
||||
"peer_encrypt",
|
||||
"peer_verify_and_decrypt",
|
||||
"pubkey_bytes",
|
||||
"raw_peer_encrypt",
|
||||
"register_channel",
|
||||
"sign",
|
||||
"verify_advert_signature",
|
||||
]
|
||||
@@ -0,0 +1,453 @@
|
||||
"""MeshCore packet parsing/building, routing, dedup, node registry, ADVERT, ACK, PATH."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
from orac.constants import (
|
||||
DEDUP_TTL,
|
||||
DM_DEDUP_TTL,
|
||||
PENDING_DM_TTL,
|
||||
RESP_SERVER_LOGIN_OK,
|
||||
)
|
||||
from orac.crypto import (
|
||||
build_advert_payload,
|
||||
ecdh_shared_secret,
|
||||
grp_encrypt,
|
||||
my_hash,
|
||||
peer_encrypt,
|
||||
raw_peer_encrypt,
|
||||
verify_advert_signature,
|
||||
)
|
||||
|
||||
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]] = {}
|
||||
|
||||
_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:
|
||||
"""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).
|
||||
"""
|
||||
existing = _route_table.get(src_hash)
|
||||
if existing is not None:
|
||||
_, existing_hs = existing
|
||||
if hash_size < existing_hs:
|
||||
return # Don't downgrade hash size
|
||||
_route_table[src_hash] = (list(reversed(path_hops)), hash_size)
|
||||
|
||||
|
||||
def get_route(dest_hash: int) -> tuple[list[bytes], int] | None:
|
||||
"""Get a learned return route for a destination, or None if unknown."""
|
||||
return _route_table.get(dest_hash)
|
||||
|
||||
|
||||
def route_name(route_type: int) -> str:
|
||||
"""Human-readable name for a route type."""
|
||||
return _RT_NAMES.get(route_type, f"rt{route_type}")
|
||||
|
||||
|
||||
# ── Node registry ────────────────────────────────────────────────
|
||||
# Stored in state module; these helpers bridge to it.
|
||||
|
||||
from orac import state as _state_mod # noqa: E402
|
||||
|
||||
|
||||
def register_node(pubkey: bytes, name: str) -> bool:
|
||||
"""Register a node. Returns True if this is a NEW node."""
|
||||
return _state_mod.register_node(pubkey, name)
|
||||
|
||||
|
||||
def lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]:
|
||||
"""Find all known nodes whose pubkey first byte matches."""
|
||||
return _state_mod.lookup_node_by_hash(hash_byte)
|
||||
|
||||
|
||||
def node_name(pubkey_hex: str) -> str:
|
||||
"""Human-readable name for a node, or truncated hex."""
|
||||
return _state_mod.node_name(pubkey_hex)
|
||||
|
||||
|
||||
# ── Deduplication ────────────────────────────────────────────────
|
||||
|
||||
_seen_packets: dict[bytes, float] = {}
|
||||
_seen_dm_texts: dict[str, float] = {} # "pubkey_hex:text" -> monotonic timestamp
|
||||
|
||||
|
||||
def is_duplicate(payload_type: int, payload: bytes) -> bool:
|
||||
"""Dedup via 8-byte packet hash (spec Section 16)."""
|
||||
now = time.monotonic()
|
||||
if len(_seen_packets) > 500:
|
||||
expired = [k for k, t in _seen_packets.items() if now - t > DEDUP_TTL]
|
||||
for k in expired:
|
||||
del _seen_packets[k]
|
||||
|
||||
pkt_hash = hashlib.sha256(bytes([payload_type]) + payload).digest()[:8]
|
||||
if pkt_hash in _seen_packets and now - _seen_packets[pkt_hash] < DEDUP_TTL:
|
||||
return True
|
||||
_seen_packets[pkt_hash] = now
|
||||
return False
|
||||
|
||||
|
||||
def is_dm_duplicate(peer_pubkey_hex: str, text: str) -> bool:
|
||||
"""Dedup on decrypted DM text (catches retries with different attempt counters)."""
|
||||
now = time.monotonic()
|
||||
if len(_seen_dm_texts) > 200:
|
||||
expired = [k for k, t in _seen_dm_texts.items() if now - t > DM_DEDUP_TTL]
|
||||
for k in expired:
|
||||
del _seen_dm_texts[k]
|
||||
|
||||
key = f"{peer_pubkey_hex}:{text}"
|
||||
if key in _seen_dm_texts and now - _seen_dm_texts[key] < DM_DEDUP_TTL:
|
||||
return True
|
||||
_seen_dm_texts[key] = now
|
||||
return False
|
||||
|
||||
|
||||
# ── Pending DMs from unknown senders ─────────────────────────────
|
||||
|
||||
_pending_dms: dict[int, list[tuple[bytes, float]]] = {}
|
||||
|
||||
|
||||
def queue_pending_dm(src_hash: int, raw_payload: bytes) -> bool:
|
||||
"""Queue a DM from an unknown sender. Returns True if this is the first queued."""
|
||||
_pending_dms.setdefault(src_hash, []).append((raw_payload, time.monotonic()))
|
||||
return len(_pending_dms[src_hash]) == 1
|
||||
|
||||
|
||||
def pop_pending_dms(node_hash: int) -> list[tuple[bytes, float]]:
|
||||
"""Pop and return all pending DMs for a node hash."""
|
||||
return _pending_dms.pop(node_hash, [])
|
||||
|
||||
|
||||
def has_pending_dms(node_hash: int) -> bool:
|
||||
"""Check if there are pending DMs for a node hash."""
|
||||
return bool(_pending_dms.get(node_hash))
|
||||
|
||||
|
||||
def is_pending_expired(timestamp: float) -> bool:
|
||||
"""Check if a pending DM timestamp is expired."""
|
||||
return time.monotonic() - timestamp > PENDING_DM_TTL
|
||||
|
||||
|
||||
# ── Packet parsing ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_header_and_path(
|
||||
packet: bytes,
|
||||
) -> tuple[int, int, int, int, bytes, list[bytes], int] | None:
|
||||
"""Parse MeshCore header + path.
|
||||
|
||||
Returns (payload_type, route_type, payload_ver, pos, payload, path_hops, hash_size)
|
||||
or None. path_hops is a list of raw hash bytes for each hop (closest relay first).
|
||||
"""
|
||||
if len(packet) < 3:
|
||||
return None
|
||||
|
||||
header = packet[0]
|
||||
route_type = header & 0x03
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
payload_ver = (header >> 6) & 0x03
|
||||
|
||||
if payload_ver != 0:
|
||||
return None
|
||||
|
||||
has_tc = route_type in (0, 3)
|
||||
|
||||
for skip_tc in [True, False] if has_tc else [False]:
|
||||
pos = 5 if skip_tc else 1
|
||||
if pos >= len(packet):
|
||||
continue
|
||||
|
||||
path_len_byte = packet[pos]
|
||||
pos += 1
|
||||
hash_size_code = path_len_byte >> 6
|
||||
if hash_size_code == 3:
|
||||
continue
|
||||
hash_size = hash_size_code + 1
|
||||
hop_count = path_len_byte & 0x3F
|
||||
path_bytes = hop_count * hash_size
|
||||
if path_bytes > 64 or pos + path_bytes > len(packet):
|
||||
continue
|
||||
|
||||
hops: list[bytes] = []
|
||||
for i in range(hop_count):
|
||||
hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size])
|
||||
pos += path_bytes
|
||||
|
||||
payload = packet[pos:]
|
||||
if payload and len(payload) <= 184:
|
||||
return payload_type, route_type, payload_ver, pos, payload, hops, hash_size
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Packet building ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_routed_packet(payload_type: int, payload: bytes, dest_hash: int) -> bytes:
|
||||
"""Build a packet using a learned route if available, otherwise flood."""
|
||||
route = get_route(dest_hash)
|
||||
if route is not None:
|
||||
hops, hash_size = route
|
||||
hash_size_code = hash_size - 1
|
||||
path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F)
|
||||
path_data = b"".join(hops)
|
||||
header = bytes([(payload_type << 2) | 2]) # direct route_type = 2
|
||||
return header + bytes([path_len_byte]) + path_data + payload
|
||||
else:
|
||||
header = bytes([(payload_type << 2) | 1]) # flood route_type = 1
|
||||
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
|
||||
return header + path_len + payload
|
||||
|
||||
|
||||
def build_grp_packet(channel_payload: bytes) -> bytes:
|
||||
"""Wrap channel payload in a GRP_TXT flood packet."""
|
||||
header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1
|
||||
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
|
||||
return header + path_len + channel_payload
|
||||
|
||||
|
||||
def build_advert_packet() -> bytes:
|
||||
"""Build a MeshCore ADVERT packet for this node."""
|
||||
advert_payload = build_advert_payload()
|
||||
header = bytes([0x11]) # ADVERT flood: (4 << 2) | 1
|
||||
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
|
||||
return header + path_len + advert_payload
|
||||
|
||||
|
||||
def build_dm_packet(peer_pubkey: bytes, text: str) -> bytes:
|
||||
"""Build a TXT_MSG DM packet using learned route or flood."""
|
||||
shared_secret = ecdh_shared_secret(peer_pubkey)
|
||||
mac_ct = peer_encrypt(shared_secret, text)
|
||||
dm_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
|
||||
return _build_routed_packet(0x02, dm_payload, peer_pubkey[0])
|
||||
|
||||
|
||||
def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes:
|
||||
"""Build an ACK packet using learned route or flood."""
|
||||
return _build_routed_packet(0x03, ack_crc, 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."""
|
||||
reversed_hops = list(reversed(path_hops))
|
||||
hash_size_code = hash_size - 1
|
||||
path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F)
|
||||
path_data = b"".join(reversed_hops)
|
||||
|
||||
# Inner plaintext: path_len + path + 0xFF (dummy extra) + 4 random bytes
|
||||
inner = bytes([path_len_byte]) + path_data + b"\xff" + os.urandom(4)
|
||||
|
||||
shared_secret = ecdh_shared_secret(peer_pubkey)
|
||||
mac_ct = raw_peer_encrypt(shared_secret, inner)
|
||||
|
||||
# Outer: dest_hash + src_hash + mac + ciphertext
|
||||
path_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
|
||||
return _build_routed_packet(0x08, path_payload, peer_pubkey[0])
|
||||
|
||||
|
||||
def build_login_response_packet(peer_pubkey: bytes) -> bytes:
|
||||
"""Build a RESPONSE packet (login OK) back to the peer."""
|
||||
shared_secret = ecdh_shared_secret(peer_pubkey)
|
||||
|
||||
# Response payload: timestamp(4) + RESP_SERVER_LOGIN_OK(1) + zero(1) + permissions(2)
|
||||
reply = struct.pack("<I", int(time.time()))
|
||||
reply += bytes([RESP_SERVER_LOGIN_OK, 0x00, 0x00, 0xFF])
|
||||
|
||||
mac_ct = raw_peer_encrypt(shared_secret, reply)
|
||||
resp_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
|
||||
return _build_routed_packet(0x01, resp_payload, peer_pubkey[0])
|
||||
|
||||
|
||||
def build_grp_channel_packet(channel_name: str, sender: str, text: str) -> bytes | None:
|
||||
"""Build a complete GRP_TXT packet for a channel. Returns None if channel unknown."""
|
||||
from orac.crypto import get_channel_secret
|
||||
|
||||
secret = get_channel_secret(channel_name)
|
||||
if secret is None:
|
||||
return None
|
||||
payload = grp_encrypt(secret, sender, text)
|
||||
return build_grp_packet(payload)
|
||||
|
||||
|
||||
# ── ACK hash computation ────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_ack_hash(plaintext: bytes, sender_pubkey: bytes) -> bytes:
|
||||
"""Compute 4-byte ACK hash per MeshCore BaseChatMesh.cpp.
|
||||
|
||||
Hash = SHA-256(frag1 || sender_pubkey)[0:4]
|
||||
frag1 = timestamp(4) + txt_type_attempt(1) + text (no null terminator)
|
||||
"""
|
||||
text_bytes = plaintext[5:]
|
||||
null_pos = text_bytes.find(b"\x00")
|
||||
text_len = null_pos if null_pos >= 0 else len(text_bytes)
|
||||
frag1 = plaintext[: 5 + text_len]
|
||||
return hashlib.sha256(frag1 + sender_pubkey).digest()[:4]
|
||||
|
||||
|
||||
# ── GRP_TXT interception ────────────────────────────────────────
|
||||
|
||||
|
||||
def try_decrypt_grp(raw_payload: bytes) -> tuple[str, str] | None:
|
||||
"""Try to decrypt a GRP_TXT payload. Returns (channel_name, text) or None."""
|
||||
from orac.crypto import get_channels_by_hash, grp_verify_and_decrypt, parse_grp_plaintext
|
||||
|
||||
if len(raw_payload) < 19:
|
||||
return None
|
||||
ch = raw_payload[0]
|
||||
mac_bytes = raw_payload[1:3]
|
||||
ciphertext = raw_payload[3:]
|
||||
|
||||
candidates = get_channels_by_hash(ch)
|
||||
for chan_name, secret in candidates:
|
||||
plaintext = grp_verify_and_decrypt(secret, mac_bytes, ciphertext)
|
||||
if plaintext is not None:
|
||||
parsed = parse_grp_plaintext(plaintext)
|
||||
if parsed:
|
||||
_, text = parsed
|
||||
return chan_name, text
|
||||
return None
|
||||
|
||||
|
||||
# ── DM interception ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def try_decrypt_dm(raw_payload: bytes) -> tuple[bytes, str, str, bytes] | None:
|
||||
"""Try to decrypt a TXT_MSG DM addressed to us.
|
||||
|
||||
Returns (sender_pubkey, sender_name, message_text, raw_plaintext) or None.
|
||||
"""
|
||||
from orac.crypto import parse_peer_plaintext, peer_verify_and_decrypt
|
||||
|
||||
if len(raw_payload) < 20:
|
||||
return None
|
||||
|
||||
dest_hash = raw_payload[0]
|
||||
src_hash = raw_payload[1]
|
||||
mac_bytes = raw_payload[2:4]
|
||||
ciphertext = raw_payload[4:]
|
||||
|
||||
log.debug(
|
||||
"TXT_MSG dest=0x%02x src=0x%02x me=0x%02x [%dB]",
|
||||
dest_hash,
|
||||
src_hash,
|
||||
my_hash(),
|
||||
len(ciphertext),
|
||||
)
|
||||
|
||||
if dest_hash != my_hash():
|
||||
return None
|
||||
|
||||
candidates = lookup_node_by_hash(src_hash)
|
||||
if not candidates:
|
||||
log.warning("DM for us but no known node with hash 0x%02x", src_hash)
|
||||
return None
|
||||
|
||||
for peer_pubkey, peer_name in candidates:
|
||||
try:
|
||||
shared_secret = ecdh_shared_secret(peer_pubkey)
|
||||
plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
|
||||
if plaintext is not None:
|
||||
text = parse_peer_plaintext(plaintext)
|
||||
if text:
|
||||
return peer_pubkey, peer_name, text, plaintext
|
||||
except Exception:
|
||||
pk_hex = peer_pubkey.hex()
|
||||
log.warning("Evicting bad key for %s (%s...)", peer_name, pk_hex[:16])
|
||||
_state_mod.evict_node(pk_hex)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def try_decrypt_anon_req(
|
||||
raw_payload: bytes,
|
||||
) -> tuple[bytes, str, bytes | None] | None:
|
||||
"""Try to decrypt an ANON_REQ (login).
|
||||
|
||||
Returns (sender_pubkey, sender_name, plaintext_or_None) or None.
|
||||
"""
|
||||
from orac.crypto import peer_verify_and_decrypt
|
||||
|
||||
if len(raw_payload) < 51:
|
||||
return None
|
||||
|
||||
dest_hash = raw_payload[0]
|
||||
sender_pubkey = raw_payload[1:33]
|
||||
mac_bytes = raw_payload[33:35]
|
||||
ciphertext = raw_payload[35:]
|
||||
|
||||
log.info(
|
||||
"ANON_REQ dest=0x%02x sender=%s... [%dB]",
|
||||
dest_hash,
|
||||
sender_pubkey.hex()[:16],
|
||||
len(ciphertext),
|
||||
)
|
||||
|
||||
if dest_hash != my_hash():
|
||||
return None
|
||||
|
||||
# Register sender -- we now have their full pubkey
|
||||
peer_name = node_name(sender_pubkey.hex())
|
||||
is_new = register_node(sender_pubkey, peer_name)
|
||||
if is_new:
|
||||
log.info(
|
||||
"Registered new peer from ANON_REQ: %s (hash=0x%02x)",
|
||||
peer_name,
|
||||
sender_pubkey[0],
|
||||
)
|
||||
|
||||
try:
|
||||
shared_secret = ecdh_shared_secret(sender_pubkey)
|
||||
plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
|
||||
if plaintext is not None:
|
||||
return sender_pubkey, peer_name, plaintext
|
||||
else:
|
||||
log.warning("ANON_REQ MAC mismatch")
|
||||
except Exception as e:
|
||||
log.error("ANON_REQ decrypt error: %s", e)
|
||||
|
||||
return sender_pubkey, peer_name, None
|
||||
|
||||
|
||||
def try_decode_advert(raw_payload: bytes) -> tuple[bytes, str] | None:
|
||||
"""Decode and verify an ADVERT payload. Returns (pubkey, name) or None."""
|
||||
if len(raw_payload) < 100:
|
||||
return None
|
||||
|
||||
pubkey = raw_payload[0:32]
|
||||
timestamp = raw_payload[32:36]
|
||||
signature = raw_payload[36:100]
|
||||
app_data = raw_payload[100:]
|
||||
|
||||
if not verify_advert_signature(pubkey, timestamp, app_data, signature):
|
||||
return None
|
||||
|
||||
name = ""
|
||||
if app_data:
|
||||
flags = app_data[0]
|
||||
pos = 1
|
||||
if flags & 0x10: # has location
|
||||
pos += 8
|
||||
if flags & 0x20: # feat1
|
||||
pos += 2
|
||||
if flags & 0x40: # feat2
|
||||
pos += 2
|
||||
if flags & 0x80 and pos < len(app_data): # has name
|
||||
name = app_data[pos:].decode("utf-8", errors="replace")
|
||||
|
||||
return pubkey, name if name else pubkey.hex()[:8]
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
"""Persistent state (JSON), history tracking, rate limiting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from orac.constants import (
|
||||
CHANNEL_HISTORY_SIZE,
|
||||
DATA_DIR,
|
||||
DM_HISTORY_SIZE,
|
||||
STATE_FILE,
|
||||
)
|
||||
|
||||
log = logging.getLogger("orac")
|
||||
|
||||
# ── Persistent state ─────────────────────────────────────────────
|
||||
|
||||
_state: dict[str, dict[str, object]] = {
|
||||
"channel_history": {}, # channel_name -> list of messages
|
||||
"dm_history": {}, # peer_pubkey_hex -> list of messages
|
||||
"known_nodes": {}, # pubkey_hex -> {"name": str, "seen": float}
|
||||
}
|
||||
|
||||
|
||||
def load_state() -> None:
|
||||
"""Load persisted state from disk."""
|
||||
global _state
|
||||
if STATE_FILE.is_file():
|
||||
try:
|
||||
with open(STATE_FILE) as f:
|
||||
loaded = json.load(f)
|
||||
for key in _state:
|
||||
if key in loaded:
|
||||
_state[key] = loaded[key]
|
||||
log.info("Loaded state from %s", STATE_FILE)
|
||||
except Exception as e:
|
||||
log.warning("Failed to load state: %s", e)
|
||||
|
||||
|
||||
def save_state() -> None:
|
||||
"""Persist state to disk (atomic write via tmp + rename)."""
|
||||
try:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(_state, f, indent=2)
|
||||
tmp.rename(STATE_FILE)
|
||||
except Exception as e:
|
||||
log.error("Failed to save state: %s", e)
|
||||
|
||||
|
||||
# ── Node registry (state-backed) ────────────────────────────────
|
||||
|
||||
|
||||
def register_node(pubkey: bytes, name: str) -> bool:
|
||||
"""Register a node. Returns True if this is a NEW node (not just an update)."""
|
||||
pk_hex = pubkey.hex()
|
||||
is_new = pk_hex not in _state["known_nodes"]
|
||||
_state["known_nodes"][pk_hex] = {"name": name, "seen": time.time()} # type: ignore[index]
|
||||
save_state()
|
||||
return is_new
|
||||
|
||||
|
||||
def lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]:
|
||||
"""Find all known nodes whose pubkey first byte matches.
|
||||
|
||||
Never expires keys -- once we learn a peer's pubkey, we can always decrypt
|
||||
their DMs. Peers shouldn't need to re-advertise just to message the bot.
|
||||
"""
|
||||
results: list[tuple[bytes, str]] = []
|
||||
for pk_hex, info in _state["known_nodes"].items(): # type: ignore[union-attr]
|
||||
pk_bytes = bytes.fromhex(pk_hex)
|
||||
if pk_bytes[0] == hash_byte:
|
||||
results.append((pk_bytes, info["name"])) # type: ignore[index]
|
||||
return results
|
||||
|
||||
|
||||
def node_name(pubkey_hex: str) -> str:
|
||||
"""Human-readable name for a node, or truncated hex."""
|
||||
info = _state["known_nodes"].get(pubkey_hex) # type: ignore[union-attr]
|
||||
return info["name"] if info else pubkey_hex[:8] # type: ignore[index]
|
||||
|
||||
|
||||
def evict_node(pubkey_hex: str) -> None:
|
||||
"""Remove a node from the registry (e.g., bad key)."""
|
||||
_state["known_nodes"].pop(pubkey_hex, None) # type: ignore[union-attr]
|
||||
save_state()
|
||||
|
||||
|
||||
def known_node_count() -> int:
|
||||
"""Number of known nodes."""
|
||||
return len(_state["known_nodes"])
|
||||
|
||||
|
||||
# ── Channel history ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def record_channel_msg(channel: str, text: str) -> None:
|
||||
"""Append a message to channel history and persist."""
|
||||
hist = _state["channel_history"]
|
||||
if channel not in hist: # type: ignore[operator]
|
||||
hist[channel] = [] # type: ignore[index]
|
||||
hist[channel].append(text) # type: ignore[index]
|
||||
if len(hist[channel]) > CHANNEL_HISTORY_SIZE: # type: ignore[index]
|
||||
hist[channel] = hist[channel][-CHANNEL_HISTORY_SIZE:] # type: ignore[index]
|
||||
save_state()
|
||||
|
||||
|
||||
def get_channel_history(channel: str) -> list[str]:
|
||||
"""Get recent channel history."""
|
||||
return list(_state["channel_history"].get(channel, [])) # type: ignore[union-attr]
|
||||
|
||||
|
||||
# ── DM history ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def record_dm_msg(peer_pubkey_hex: str, text: str) -> None:
|
||||
"""Append a message to DM history and persist."""
|
||||
hist = _state["dm_history"]
|
||||
if peer_pubkey_hex not in hist: # type: ignore[operator]
|
||||
hist[peer_pubkey_hex] = [] # type: ignore[index]
|
||||
hist[peer_pubkey_hex].append(text) # type: ignore[index]
|
||||
if len(hist[peer_pubkey_hex]) > DM_HISTORY_SIZE: # type: ignore[index]
|
||||
hist[peer_pubkey_hex] = hist[peer_pubkey_hex][-DM_HISTORY_SIZE:] # type: ignore[index]
|
||||
save_state()
|
||||
|
||||
|
||||
def get_dm_history(peer_pubkey_hex: str) -> list[str]:
|
||||
"""Get recent DM history for a peer."""
|
||||
return list(_state["dm_history"].get(peer_pubkey_hex, [])) # type: ignore[union-attr]
|
||||
Reference in new issue
Block a user