diff --git a/app/events.py b/app/events.py index 51c6ecb..35a9b87 100644 --- a/app/events.py +++ b/app/events.py @@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict): message_id: int ack_count: int paths: NotRequired[list[MessagePath]] + packet_id: NotRequired[int | None] class ToastPayload(TypedDict): diff --git a/app/models.py b/app/models.py index 650ec18..e2774d8 100644 --- a/app/models.py +++ b/app/models.py @@ -413,6 +413,10 @@ class Message(BaseModel): acked: int = 0 sender_name: str | None = None channel_name: str | None = None + packet_id: int | None = Field( + default=None, + description="Representative raw packet row ID when archival raw bytes exist", + ) class MessagesAroundResponse(BaseModel): @@ -458,6 +462,21 @@ class RawPacketBroadcast(BaseModel): decrypted_info: RawPacketDecryptedInfo | None = None +class RawPacketDetail(BaseModel): + """Stored raw-packet detail returned by the packet API.""" + + id: int + timestamp: int + data: str = Field(description="Hex-encoded packet data") + payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)") + snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available") + rssi: int | None = Field( + default=None, description="Received signal strength in dBm if available" + ) + decrypted: bool = False + decrypted_info: RawPacketDecryptedInfo | None = None + + class SendMessageRequest(BaseModel): text: str = Field(min_length=1) diff --git a/app/repository/messages.py b/app/repository/messages.py index 945017e..ad1559b 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -331,6 +331,12 @@ class MessageRepository: @staticmethod def _row_to_message(row: Any) -> Message: """Convert a database row to a Message model.""" + packet_id = None + if hasattr(row, "keys"): + row_keys = row.keys() + if "packet_id" in row_keys: + packet_id = row["packet_id"] + return Message( id=row["id"], type=row["type"], @@ -345,6 +351,14 @@ class MessageRepository: outgoing=bool(row["outgoing"]), acked=row["acked"], sender_name=row["sender_name"], + packet_id=packet_id, + ) + + @staticmethod + def _message_select(message_alias: str = "messages") -> str: + return ( + f"{message_alias}.*, " + f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id" ) @staticmethod @@ -363,7 +377,7 @@ class MessageRepository: ) -> list[Message]: search_query = MessageRepository._parse_search_query(q) if q else None query = ( - "SELECT messages.* FROM messages " + f"SELECT {MessageRepository._message_select('messages')} FROM messages " "LEFT JOIN contacts ON messages.type = 'PRIV' " "AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) " "LEFT JOIN channels ON messages.type = 'CHAN' " @@ -470,7 +484,8 @@ class MessageRepository: # 1. Get the target message (must satisfy filters if provided) target_cursor = await db.conn.execute( - f"SELECT * FROM messages WHERE id = ? AND {where_sql}", + f"SELECT {MessageRepository._message_select('messages')} " + f"FROM messages WHERE id = ? AND {where_sql}", (message_id, *base_params), ) target_row = await target_cursor.fetchone() @@ -481,7 +496,7 @@ class MessageRepository: # 2. Get context_size+1 messages before target (DESC) before_query = f""" - SELECT * FROM messages WHERE {where_sql} + SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql} AND (received_at < ? OR (received_at = ? AND id < ?)) ORDER BY received_at DESC, id DESC LIMIT ? """ @@ -500,7 +515,7 @@ class MessageRepository: # 3. Get context_size+1 messages after target (ASC) after_query = f""" - SELECT * FROM messages WHERE {where_sql} + SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql} AND (received_at > ? OR (received_at = ? AND id > ?)) ORDER BY received_at ASC, id ASC LIMIT ? """ @@ -545,7 +560,7 @@ class MessageRepository: async def get_by_id(message_id: int) -> "Message | None": """Look up a message by its ID.""" cursor = await db.conn.execute( - "SELECT * FROM messages WHERE id = ?", + f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?", (message_id,), ) row = await cursor.fetchone() @@ -570,7 +585,9 @@ class MessageRepository: ) -> "Message | None": """Look up a message by its unique content fields.""" query = """ - SELECT * FROM messages + SELECT messages.*, + (SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id + FROM messages WHERE type = ? AND conversation_key = ? AND text = ? AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL)) """ diff --git a/app/repository/raw_packets.py b/app/repository/raw_packets.py index 3a31e23..c773a67 100644 --- a/app/repository/raw_packets.py +++ b/app/repository/raw_packets.py @@ -121,6 +121,18 @@ class RawPacketRepository: return None return row["message_id"] + @staticmethod + async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None: + """Return a raw packet row as (id, data, timestamp, message_id).""" + cursor = await db.conn.execute( + "SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?", + (packet_id,), + ) + row = await cursor.fetchone() + if not row: + return None + return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"]) + @staticmethod async def prune_old_undecrypted(max_age_days: int) -> int: """Delete undecrypted packets older than max_age_days. Returns count deleted.""" diff --git a/app/routers/packets.py b/app/routers/packets.py index 00316ee..4c6374c 100644 --- a/app/routers/packets.py +++ b/app/routers/packets.py @@ -8,8 +8,9 @@ from pydantic import BaseModel, Field from app.database import db from app.decoder import parse_packet, try_decrypt_packet_with_channel_key +from app.models import RawPacketDecryptedInfo, RawPacketDetail from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption -from app.repository import ChannelRepository, RawPacketRepository +from app.repository import ChannelRepository, MessageRepository, RawPacketRepository from app.websocket import broadcast_success logger = logging.getLogger(__name__) @@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict: return {"count": count} +@router.get("/{packet_id}", response_model=RawPacketDetail) +async def get_raw_packet(packet_id: int) -> RawPacketDetail: + """Fetch one stored raw packet by row ID for on-demand inspection.""" + packet_row = await RawPacketRepository.get_by_id(packet_id) + if packet_row is None: + raise HTTPException(status_code=404, detail="Raw packet not found") + + stored_packet_id, packet_data, packet_timestamp, message_id = packet_row + packet_info = parse_packet(packet_data) + payload_type_name = packet_info.payload_type.name if packet_info else "Unknown" + + decrypted_info: RawPacketDecryptedInfo | None = None + if message_id is not None: + message = await MessageRepository.get_by_id(message_id) + if message is not None: + if message.type == "CHAN": + channel = await ChannelRepository.get_by_key(message.conversation_key) + decrypted_info = RawPacketDecryptedInfo( + channel_name=channel.name if channel else None, + sender=message.sender_name, + channel_key=message.conversation_key, + contact_key=message.sender_key, + ) + else: + decrypted_info = RawPacketDecryptedInfo( + sender=message.sender_name, + contact_key=message.conversation_key, + ) + + return RawPacketDetail( + id=stored_packet_id, + timestamp=packet_timestamp, + data=packet_data.hex(), + payload_type=payload_type_name, + decrypted=message_id is not None, + decrypted_info=decrypted_info, + ) + + @router.post("/decrypt/historical", response_model=DecryptResult) async def decrypt_historical_packets( request: DecryptRequest, background_tasks: BackgroundTasks, response: Response diff --git a/app/services/dm_ingest.py b/app/services/dm_ingest.py index df01e46..bfd09ca 100644 --- a/app/services/dm_ingest.py +++ b/app/services/dm_ingest.py @@ -238,6 +238,7 @@ async def _store_direct_message( sender_key=sender_key, outgoing=outgoing, sender_name=sender_name, + packet_id=packet_id, ) broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime) diff --git a/app/services/messages.py b/app/services/messages.py index 5508a6a..f5d1ea9 100644 --- a/app/services/messages.py +++ b/app/services/messages.py @@ -62,6 +62,7 @@ def build_message_model( acked: int = 0, sender_name: str | None = None, channel_name: str | None = None, + packet_id: int | None = None, ) -> Message: """Build a Message model with the canonical backend payload shape.""" return Message( @@ -79,6 +80,7 @@ def build_message_model( acked=acked, sender_name=sender_name, channel_name=channel_name, + packet_id=packet_id, ) @@ -131,6 +133,7 @@ def broadcast_message_acked( message_id: int, ack_count: int, paths: list[MessagePath] | None, + packet_id: int | None, broadcast_fn: BroadcastFn, ) -> None: """Broadcast a message_acked payload.""" @@ -140,6 +143,7 @@ def broadcast_message_acked( "message_id": message_id, "ack_count": ack_count, "paths": [path.model_dump() for path in paths] if paths else [], + "packet_id": packet_id, }, ) @@ -182,11 +186,16 @@ async def reconcile_duplicate_message( else: ack_count = existing_msg.acked + representative_packet_id = ( + existing_msg.packet_id if existing_msg.packet_id is not None else packet_id + ) + if existing_msg.outgoing or path is not None: broadcast_message_acked( message_id=existing_msg.id, ack_count=ack_count, paths=paths, + packet_id=representative_packet_id, broadcast_fn=broadcast_fn, ) @@ -307,6 +316,7 @@ async def create_message_from_decrypted( sender_name=sender, sender_key=resolved_sender_key, channel_name=channel_name, + packet_id=packet_id, ), broadcast_fn=broadcast_fn, realtime=realtime, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 56a610c..0c70c54 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,6 +15,7 @@ import type { MessagesAroundResponse, MigratePreferencesRequest, MigratePreferencesResponse, + RawPacket, RadioAdvertMode, RadioConfig, RadioConfigUpdate, @@ -247,6 +248,7 @@ export const api = { ), // Packets + getPacket: (packetId: number) => fetchJson(`/packets/${packetId}`), getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'), decryptHistoricalPackets: (params: { key_type: 'channel' | 'contact'; diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index c2f2ced..809fda3 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -261,6 +261,7 @@ export function ConversationPane({ key={activeConversation.id} messages={messages} contacts={contacts} + channels={channels} loading={messagesLoading} loadingOlder={loadingOlder} hasOlderMessages={hasOlderMessages} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index fedcbaf..06f46f8 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -8,19 +8,23 @@ import { useState, type ReactNode, } from 'react'; -import type { Contact, Message, MessagePath, RadioConfig } from '../types'; +import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; +import { api } from '../api'; import { formatTime, parseSenderFromText } from '../utils/messageParser'; import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; import { getDirectContactRoute } from '../utils/pathUtils'; import { ContactAvatar } from './ContactAvatar'; import { PathModal } from './PathModal'; +import { RawPacketInspectorDialog } from './RawPacketDetailModal'; +import { toast } from './ui/sonner'; import { handleKeyboardActivate } from '../utils/a11y'; import { cn } from '@/lib/utils'; interface MessageListProps { messages: Message[]; contacts: Contact[]; + channels?: Channel[]; loading: boolean; loadingOlder?: boolean; hasOlderMessages?: boolean; @@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) { const RESEND_WINDOW_SECONDS = 30; const CORRUPT_SENDER_LABEL = ''; +const ANALYZE_PACKET_NOTICE = + 'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.'; function hasUnexpectedControlChars(text: string): boolean { for (const char of text) { @@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean { export function MessageList({ messages, contacts, + channels = [], loading, loadingOlder = false, hasOlderMessages = false, @@ -199,10 +206,18 @@ export function MessageList({ paths: MessagePath[]; senderInfo: SenderInfo; messageId?: number; + packetId?: number | null; isOutgoingChan?: boolean; } | null>(null); const [resendableIds, setResendableIds] = useState>(new Set()); const resendTimersRef = useRef>>(new Map()); + const packetCacheRef = useRef>(new Map()); + const [packetInspectorSource, setPacketInspectorSource] = useState< + | { kind: 'packet'; packet: RawPacket } + | { kind: 'loading'; message: string } + | { kind: 'unavailable'; message: string } + | null + >(null); const [highlightedMessageId, setHighlightedMessageId] = useState(null); const [showJumpToUnread, setShowJumpToUnread] = useState(false); const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false); @@ -221,6 +236,43 @@ export function MessageList({ // Track conversation key to detect when entire message set changes const prevConvKeyRef = useRef(null); + const handleAnalyzePacket = useCallback(async (message: Message) => { + if (message.packet_id == null) { + setPacketInspectorSource({ + kind: 'unavailable', + message: + 'No archival raw packet is available for this message, so packet analysis cannot be shown.', + }); + return; + } + + const cached = packetCacheRef.current.get(message.packet_id); + if (cached) { + setPacketInspectorSource({ kind: 'packet', packet: cached }); + return; + } + + setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' }); + + try { + const packet = await api.getPacket(message.packet_id); + packetCacheRef.current.set(message.packet_id, packet); + setPacketInspectorSource({ kind: 'packet', packet }); + } catch (error) { + const description = error instanceof Error ? error.message : 'Unknown error'; + const isMissing = error instanceof Error && /not found/i.test(error.message); + if (!isMissing) { + toast.error('Failed to load raw packet', { description }); + } + setPacketInspectorSource({ + kind: 'unavailable', + message: isMissing + ? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.' + : `Could not load the archival raw packet for this message: ${description}`, + }); + } + }, []); + // Handle scroll position AFTER render useLayoutEffect(() => { if (!listRef.current) return; @@ -833,6 +885,8 @@ export function MessageList({ setSelectedPath({ paths: msg.paths!, senderInfo: getSenderInfo(msg, contact, directSenderName || sender), + messageId: msg.id, + packetId: msg.packet_id, }) } /> @@ -859,6 +913,8 @@ export function MessageList({ setSelectedPath({ paths: msg.paths!, senderInfo: getSenderInfo(msg, contact, directSenderName || sender), + messageId: msg.id, + packetId: msg.packet_id, }) } /> @@ -879,6 +935,7 @@ export function MessageList({ paths: msg.paths!, senderInfo: selfSenderInfo, messageId: msg.id, + packetId: msg.packet_id, isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage, }); }} @@ -900,6 +957,7 @@ export function MessageList({ paths: [], senderInfo: selfSenderInfo, messageId: msg.id, + packetId: msg.packet_id, isOutgoingChan: true, }); }} @@ -997,9 +1055,31 @@ export function MessageList({ contacts={contacts} config={config ?? null} messageId={selectedPath.messageId} + packetId={selectedPath.packetId} isOutgoingChan={selectedPath.isOutgoingChan} isResendable={isSelectedMessageResendable} onResend={onResendChannelMessage} + onAnalyzePacket={ + selectedPath.packetId != null + ? () => { + const message = messages.find((entry) => entry.id === selectedPath.messageId); + if (message) { + void handleAnalyzePacket(message); + } + } + : undefined + } + /> + )} + {packetInspectorSource && ( + !isOpen && setPacketInspectorSource(null)} + channels={channels} + source={packetInspectorSource} + title="Analyze Packet" + description="On-demand raw packet analysis for a message-backed archival packet." + notice={ANALYZE_PACKET_NOTICE} /> )} diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index e2e5f68..9ff0916 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -29,9 +29,11 @@ interface PathModalProps { contacts: Contact[]; config: RadioConfig | null; messageId?: number; + packetId?: number | null; isOutgoingChan?: boolean; isResendable?: boolean; onResend?: (messageId: number, newTimestamp?: boolean) => void; + onAnalyzePacket?: () => void; } export function PathModal({ @@ -42,14 +44,17 @@ export function PathModal({ contacts, config, messageId, + packetId, isOutgoingChan, isResendable, onResend, + onAnalyzePacket, }: PathModalProps) { const { distanceUnit } = useDistanceUnit(); const [expandedMaps, setExpandedMaps] = useState>(new Set()); const hasResendActions = isOutgoingChan && messageId !== undefined && onResend; const hasPaths = paths.length > 0; + const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket; // Resolve all paths const resolvedPaths = hasPaths @@ -90,6 +95,12 @@ export function PathModal({ {hasPaths && (
+ {showAnalyzePacket ? ( + + ) : null} + {/* Raw path summary */}
{paths.map((p, index) => { diff --git a/frontend/src/components/RawPacketDetailModal.tsx b/frontend/src/components/RawPacketDetailModal.tsx index 4498755..73c633f 100644 --- a/frontend/src/components/RawPacketDetailModal.tsx +++ b/frontend/src/components/RawPacketDetailModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder'; import type { Channel, RawPacket } from '../types'; @@ -18,6 +18,33 @@ interface RawPacketDetailModalProps { onClose: () => void; } +type RawPacketInspectorDialogSource = + | { + kind: 'packet'; + packet: RawPacket; + } + | { + kind: 'paste'; + } + | { + kind: 'loading'; + message: string; + } + | { + kind: 'unavailable'; + message: string; + }; + +interface RawPacketInspectorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + channels: Channel[]; + source: RawPacketInspectorDialogSource; + title: string; + description: string; + notice?: ReactNode; +} + interface RawPacketInspectionPanelProps { packet: RawPacket; channels: Channel[]; @@ -365,6 +392,36 @@ function renderFieldValue(field: PacketByteField) { ); } +function normalizePacketHex(input: string): string { + return input.replace(/\s+/g, '').toUpperCase(); +} + +function validatePacketHex(input: string): string | null { + if (!input) { + return 'Paste a packet hex string to analyze.'; + } + if (!/^[0-9A-F]+$/.test(input)) { + return 'Packet hex may only contain 0-9 and A-F characters.'; + } + if (input.length % 2 !== 0) { + return 'Packet hex must contain an even number of characters.'; + } + return null; +} + +function buildPastedRawPacket(packetHex: string): RawPacket { + return { + id: -1, + timestamp: Math.floor(Date.now() / 1000), + data: packetHex, + payload_type: 'Unknown', + snr: null, + rssi: null, + decrypted: false, + decrypted_info: null, + }; +} + function FieldBox({ field, palette, @@ -645,22 +702,118 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti ); } +export function RawPacketInspectorDialog({ + open, + onOpenChange, + channels, + source, + title, + description, + notice, +}: RawPacketInspectorDialogProps) { + const [packetInput, setPacketInput] = useState(''); + + useEffect(() => { + if (!open || source.kind !== 'paste') { + setPacketInput(''); + } + }, [open, source.kind]); + + const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]); + const packetInputError = useMemo( + () => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null), + [normalizedPacketInput] + ); + const analyzedPacket = useMemo( + () => + normalizedPacketInput.length > 0 && packetInputError === null + ? buildPastedRawPacket(normalizedPacketInput) + : null, + [normalizedPacketInput, packetInputError] + ); + + let body: ReactNode; + if (source.kind === 'packet') { + body = ; + } else if (source.kind === 'paste') { + body = ( + <> +
+
+ +