Files
MeshTerm/meshterm/services/message_paths.py
T
Jean-Pierre MartineauandClaude Opus 4.8 56b7580e48 Chat shows a message's delivery paths on demand (^P)
The radio's RX log has recorded every frame's relay path all along;
now a chat message can be asked how it got here. ^P floats a paths
window for the picked (or latest) message: one row per logged arrival
— time, reception SNR, and the relay chain through the shared compact
path widget — so a channel broadcast that reached us three ways reads
as three rows over two distinct paths, resends labelled.

Channel messages are matched by evidence, not guesswork: overheard
GRP_TXT frames decrypt under the channel's own key (fetched from the
device when the conversation didn't carry it) and match the message by
content, tolerating the "Name: " sender-prefix convention on the wire
— our own broadcasts included, since repeaters' rebroadcasts of us are
overheard like anything else. Direct frames are ECDH-encrypted, so the
view offers the window's TXT_MSG frames matched by time alone and says
so plainly. The sender-prefix parser moves to core.channels where the
matcher, the transcript, and the dashboard feed now all share it, and
the repository gains the windowed packet_frames_between query.

Both chat kinds now share one message-pick model: ↑ picks, the pick
walks with ↑↓/PgUp/PgDn/^Home and day-jumps with ^PgUp/^PgDn, ^End
returns to compose. Enter on a pick replies in a channel (unchanged)
and opens the paths in a direct chat, each with its banner cue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:37:57 -04:00

165 lines
6.5 KiB
Python

"""Recover the paths a chat message rode in on, from the stored packet log.
The radio's RX log records every frame it decodes — including the relay path each one
traversed — and the recorder has been persisting those as ``packet`` observations all
along. A message heard more than once (a channel broadcast rebroadcast by several
repeaters, a flood that reached us two ways) therefore left one logged frame *per
arrival*, each with its own path and SNR. This module matches those frames back to a
chat message so the chat screen can show, on demand, every way the message reached us:
* **Channel messages** match by content: a ``GRP_TXT`` frame names its channel only by
a one-byte hash, but we hold the channel's key, so
:func:`~meshterm.core.channels.decrypt_channel_text` can confirm the channel by MAC
and recover the plaintext. A frame whose text equals the message's (allowing for the
``Name: `` sender prefix convention on either side) is one arrival of that message —
our own broadcasts included, since a repeater's rebroadcast of us is overheard and
logged like anything else.
* **Direct messages** ride ECDH-encrypted ``TXT_MSG`` frames that only the companion
can decrypt, so no content match is possible from the log. Frames of that class
logged within a tight window around the message are offered instead, clearly billed
as matched by time — honest evidence, not a claim.
Nothing here transmits; it is a read-model over the repository.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Optional
from ..core.channels import decrypt_channel_text, split_channel_sender
from ..core.models import ChatMessage, Observation
if TYPE_CHECKING:
from ..persistence.repository import Repository
#: How far around a channel message the log is searched. Wide, because an inbound
#: message is stamped with the *sender's* clock, which may drift from ours by minutes.
_CHANNEL_WINDOW = timedelta(minutes=15)
#: How far around a direct message the log is searched. Tight, because time proximity
#: is the only evidence tying an encrypted frame to the message.
_DIRECT_WINDOW = timedelta(seconds=90)
#: Frame classes that carry a direct (addressed) text message.
_DIRECT_TYPENAMES = frozenset({"TXT_MSG"})
@dataclass(slots=True)
class Arrival:
"""One logged copy of a message reaching this radio.
Attributes:
when: When the frame was heard.
hops: The relay path it rode, in propagation order (empty = arrived direct).
snr: Reception SNR in dB — of the *last relay*, as with every packet row.
resend: The sender's resend counter for this copy (0 = the original send),
recovered from a decrypted channel frame; always 0 for direct frames.
"""
when: datetime
hops: tuple[str, ...]
snr: Optional[float]
resend: int = 0
def _frame_hops(observation: Observation) -> tuple[str, ...]:
"""An observation's relay path as hop hashes (empty = direct)."""
return tuple(h for h in (observation.path or "").split(",") if h)
def _texts_match(wire: str, stored: str) -> bool:
"""Whether an on-air text and a stored chat text are the same message body.
An inbound message is stored verbatim off the wire (sender prefix included), so an
exact match covers it. Our own outbound messages are stored as typed while the
radio prepends the ``Name: `` sender convention — so the wire text also matches
when its prefix-stripped body equals the stored text. Comparison is
whitespace-trimmed but otherwise exact: paths are evidence, and a fuzzy match
would fabricate some.
"""
wire, stored = wire.strip(), stored.strip()
if wire == stored:
return True
_sender, body = split_channel_sender(wire)
body = body.strip()
return bool(body) and body == stored
def channel_arrivals(
repo: "Repository",
message: ChatMessage,
*,
channel_name: str,
secret: bytes,
) -> list[Arrival]:
"""Every logged arrival of a channel message, matched by decrypted content.
Args:
repo: The repository holding the packet log.
message: The chat message whose arrivals to find (in- or outbound).
channel_name: The conversation's channel name (for the decrypt attempt).
secret: The channel's 16-byte key.
Returns:
The matching arrivals, oldest first (resends of the same message included,
each carrying its ``resend`` counter).
"""
frames = repo.packet_frames_between(
message.created_at - _CHANNEL_WINDOW, message.created_at + _CHANNEL_WINDOW
)
arrivals: list[Arrival] = []
for frame in frames:
raw = frame.raw if isinstance(frame.raw, dict) else {}
if raw.get("payload_typename") != "GRP_TXT":
continue
chan_hash = raw.get("chan_hash")
cipher_mac = raw.get("cipher_mac")
crypted = raw.get("crypted")
if not (chan_hash and cipher_mac and crypted):
continue
decrypted = decrypt_channel_text(
chan_hash, cipher_mac, crypted, [(channel_name, secret)]
)
if decrypted is None or not _texts_match(decrypted.text, message.text):
continue
arrivals.append(
Arrival(
when=frame.observed_at,
hops=_frame_hops(frame),
snr=frame.snr,
resend=decrypted.attempt,
)
)
return arrivals
def direct_frames_near(repo: "Repository", message: ChatMessage) -> list[Arrival]:
"""Direct-message frames logged around ``message``, matched by time alone.
Direct frames are encrypted to their recipient, so the log can't confirm which
message a frame carried — the caller must present these as time-correlated
evidence, not a claim (see the module docstring).
Args:
repo: The repository holding the packet log.
message: The chat message to search around.
Returns:
The window's direct-class frames, oldest first.
"""
frames = repo.packet_frames_between(
message.created_at - _DIRECT_WINDOW, message.created_at + _DIRECT_WINDOW
)
return [
Arrival(when=f.observed_at, hops=_frame_hops(f), snr=f.snr)
for f in frames
if isinstance(f.raw, dict) and f.raw.get("payload_typename") in _DIRECT_TYPENAMES
]
def distinct_paths(arrivals: list[Arrival]) -> int:
"""How many distinct relay paths a set of arrivals covers."""
return len({a.hops for a in arrivals})