Add on-receive packet analyzer for canonical copy. Closes #97.

This commit is contained in:
Jack Kingsman
2026-03-22 21:34:41 -07:00
parent d840159f9c
commit da31b67d54
24 changed files with 534 additions and 137 deletions
+1
View File
@@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict):
message_id: int message_id: int
ack_count: int ack_count: int
paths: NotRequired[list[MessagePath]] paths: NotRequired[list[MessagePath]]
packet_id: NotRequired[int | None]
class ToastPayload(TypedDict): class ToastPayload(TypedDict):
+19
View File
@@ -413,6 +413,10 @@ class Message(BaseModel):
acked: int = 0 acked: int = 0
sender_name: str | None = None sender_name: str | None = None
channel_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): class MessagesAroundResponse(BaseModel):
@@ -458,6 +462,21 @@ class RawPacketBroadcast(BaseModel):
decrypted_info: RawPacketDecryptedInfo | None = None 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): class SendMessageRequest(BaseModel):
text: str = Field(min_length=1) text: str = Field(min_length=1)
+23 -6
View File
@@ -331,6 +331,12 @@ class MessageRepository:
@staticmethod @staticmethod
def _row_to_message(row: Any) -> Message: def _row_to_message(row: Any) -> Message:
"""Convert a database row to a Message model.""" """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( return Message(
id=row["id"], id=row["id"],
type=row["type"], type=row["type"],
@@ -345,6 +351,14 @@ class MessageRepository:
outgoing=bool(row["outgoing"]), outgoing=bool(row["outgoing"]),
acked=row["acked"], acked=row["acked"],
sender_name=row["sender_name"], 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 @staticmethod
@@ -363,7 +377,7 @@ class MessageRepository:
) -> list[Message]: ) -> list[Message]:
search_query = MessageRepository._parse_search_query(q) if q else None search_query = MessageRepository._parse_search_query(q) if q else None
query = ( query = (
"SELECT messages.* FROM messages " f"SELECT {MessageRepository._message_select('messages')} FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' " "LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) " "AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
"LEFT JOIN channels ON messages.type = 'CHAN' " "LEFT JOIN channels ON messages.type = 'CHAN' "
@@ -470,7 +484,8 @@ class MessageRepository:
# 1. Get the target message (must satisfy filters if provided) # 1. Get the target message (must satisfy filters if provided)
target_cursor = await db.conn.execute( 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), (message_id, *base_params),
) )
target_row = await target_cursor.fetchone() target_row = await target_cursor.fetchone()
@@ -481,7 +496,7 @@ class MessageRepository:
# 2. Get context_size+1 messages before target (DESC) # 2. Get context_size+1 messages before target (DESC)
before_query = f""" 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 < ?)) AND (received_at < ? OR (received_at = ? AND id < ?))
ORDER BY received_at DESC, id DESC LIMIT ? ORDER BY received_at DESC, id DESC LIMIT ?
""" """
@@ -500,7 +515,7 @@ class MessageRepository:
# 3. Get context_size+1 messages after target (ASC) # 3. Get context_size+1 messages after target (ASC)
after_query = f""" 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 > ?)) AND (received_at > ? OR (received_at = ? AND id > ?))
ORDER BY received_at ASC, id ASC LIMIT ? ORDER BY received_at ASC, id ASC LIMIT ?
""" """
@@ -545,7 +560,7 @@ class MessageRepository:
async def get_by_id(message_id: int) -> "Message | None": async def get_by_id(message_id: int) -> "Message | None":
"""Look up a message by its ID.""" """Look up a message by its ID."""
cursor = await db.conn.execute( cursor = await db.conn.execute(
"SELECT * FROM messages WHERE id = ?", f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?",
(message_id,), (message_id,),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
@@ -570,7 +585,9 @@ class MessageRepository:
) -> "Message | None": ) -> "Message | None":
"""Look up a message by its unique content fields.""" """Look up a message by its unique content fields."""
query = """ 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 = ? WHERE type = ? AND conversation_key = ? AND text = ?
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL)) AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
""" """
+12
View File
@@ -121,6 +121,18 @@ class RawPacketRepository:
return None return None
return row["message_id"] 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 @staticmethod
async def prune_old_undecrypted(max_age_days: int) -> int: async def prune_old_undecrypted(max_age_days: int) -> int:
"""Delete undecrypted packets older than max_age_days. Returns count deleted.""" """Delete undecrypted packets older than max_age_days. Returns count deleted."""
+41 -1
View File
@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
from app.database import db from app.database import db
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key 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.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 from app.websocket import broadcast_success
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict:
return {"count": count} 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) @router.post("/decrypt/historical", response_model=DecryptResult)
async def decrypt_historical_packets( async def decrypt_historical_packets(
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
+1
View File
@@ -238,6 +238,7 @@ async def _store_direct_message(
sender_key=sender_key, sender_key=sender_key,
outgoing=outgoing, outgoing=outgoing,
sender_name=sender_name, sender_name=sender_name,
packet_id=packet_id,
) )
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime) broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
+10
View File
@@ -62,6 +62,7 @@ def build_message_model(
acked: int = 0, acked: int = 0,
sender_name: str | None = None, sender_name: str | None = None,
channel_name: str | None = None, channel_name: str | None = None,
packet_id: int | None = None,
) -> Message: ) -> Message:
"""Build a Message model with the canonical backend payload shape.""" """Build a Message model with the canonical backend payload shape."""
return Message( return Message(
@@ -79,6 +80,7 @@ def build_message_model(
acked=acked, acked=acked,
sender_name=sender_name, sender_name=sender_name,
channel_name=channel_name, channel_name=channel_name,
packet_id=packet_id,
) )
@@ -131,6 +133,7 @@ def broadcast_message_acked(
message_id: int, message_id: int,
ack_count: int, ack_count: int,
paths: list[MessagePath] | None, paths: list[MessagePath] | None,
packet_id: int | None,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
) -> None: ) -> None:
"""Broadcast a message_acked payload.""" """Broadcast a message_acked payload."""
@@ -140,6 +143,7 @@ def broadcast_message_acked(
"message_id": message_id, "message_id": message_id,
"ack_count": ack_count, "ack_count": ack_count,
"paths": [path.model_dump() for path in paths] if paths else [], "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: else:
ack_count = existing_msg.acked 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: if existing_msg.outgoing or path is not None:
broadcast_message_acked( broadcast_message_acked(
message_id=existing_msg.id, message_id=existing_msg.id,
ack_count=ack_count, ack_count=ack_count,
paths=paths, paths=paths,
packet_id=representative_packet_id,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
@@ -307,6 +316,7 @@ async def create_message_from_decrypted(
sender_name=sender, sender_name=sender,
sender_key=resolved_sender_key, sender_key=resolved_sender_key,
channel_name=channel_name, channel_name=channel_name,
packet_id=packet_id,
), ),
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
realtime=realtime, realtime=realtime,
+2
View File
@@ -15,6 +15,7 @@ import type {
MessagesAroundResponse, MessagesAroundResponse,
MigratePreferencesRequest, MigratePreferencesRequest,
MigratePreferencesResponse, MigratePreferencesResponse,
RawPacket,
RadioAdvertMode, RadioAdvertMode,
RadioConfig, RadioConfig,
RadioConfigUpdate, RadioConfigUpdate,
@@ -247,6 +248,7 @@ export const api = {
), ),
// Packets // Packets
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'), getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
decryptHistoricalPackets: (params: { decryptHistoricalPackets: (params: {
key_type: 'channel' | 'contact'; key_type: 'channel' | 'contact';
@@ -261,6 +261,7 @@ export function ConversationPane({
key={activeConversation.id} key={activeConversation.id}
messages={messages} messages={messages}
contacts={contacts} contacts={contacts}
channels={channels}
loading={messagesLoading} loading={messagesLoading}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages} hasOlderMessages={hasOlderMessages}
+81 -1
View File
@@ -8,19 +8,23 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react'; } 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 { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { api } from '../api';
import { formatTime, parseSenderFromText } from '../utils/messageParser'; import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils'; import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar'; import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal'; import { PathModal } from './PathModal';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface MessageListProps { interface MessageListProps {
messages: Message[]; messages: Message[];
contacts: Contact[]; contacts: Contact[];
channels?: Channel[];
loading: boolean; loading: boolean;
loadingOlder?: boolean; loadingOlder?: boolean;
hasOlderMessages?: boolean; hasOlderMessages?: boolean;
@@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
const RESEND_WINDOW_SECONDS = 30; const RESEND_WINDOW_SECONDS = 30;
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>'; const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
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 { function hasUnexpectedControlChars(text: string): boolean {
for (const char of text) { for (const char of text) {
@@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean {
export function MessageList({ export function MessageList({
messages, messages,
contacts, contacts,
channels = [],
loading, loading,
loadingOlder = false, loadingOlder = false,
hasOlderMessages = false, hasOlderMessages = false,
@@ -199,10 +206,18 @@ export function MessageList({
paths: MessagePath[]; paths: MessagePath[];
senderInfo: SenderInfo; senderInfo: SenderInfo;
messageId?: number; messageId?: number;
packetId?: number | null;
isOutgoingChan?: boolean; isOutgoingChan?: boolean;
} | null>(null); } | null>(null);
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set()); const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map()); const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
const [packetInspectorSource, setPacketInspectorSource] = useState<
| { kind: 'packet'; packet: RawPacket }
| { kind: 'loading'; message: string }
| { kind: 'unavailable'; message: string }
| null
>(null);
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null); const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const [showJumpToUnread, setShowJumpToUnread] = useState(false); const [showJumpToUnread, setShowJumpToUnread] = useState(false);
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false); const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
@@ -221,6 +236,43 @@ export function MessageList({
// Track conversation key to detect when entire message set changes // Track conversation key to detect when entire message set changes
const prevConvKeyRef = useRef<string | null>(null); const prevConvKeyRef = useRef<string | null>(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 // Handle scroll position AFTER render
useLayoutEffect(() => { useLayoutEffect(() => {
if (!listRef.current) return; if (!listRef.current) return;
@@ -833,6 +885,8 @@ export function MessageList({
setSelectedPath({ setSelectedPath({
paths: msg.paths!, paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender), senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
messageId: msg.id,
packetId: msg.packet_id,
}) })
} }
/> />
@@ -859,6 +913,8 @@ export function MessageList({
setSelectedPath({ setSelectedPath({
paths: msg.paths!, paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender), senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
messageId: msg.id,
packetId: msg.packet_id,
}) })
} }
/> />
@@ -879,6 +935,7 @@ export function MessageList({
paths: msg.paths!, paths: msg.paths!,
senderInfo: selfSenderInfo, senderInfo: selfSenderInfo,
messageId: msg.id, messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage, isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
}); });
}} }}
@@ -900,6 +957,7 @@ export function MessageList({
paths: [], paths: [],
senderInfo: selfSenderInfo, senderInfo: selfSenderInfo,
messageId: msg.id, messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: true, isOutgoingChan: true,
}); });
}} }}
@@ -997,9 +1055,31 @@ export function MessageList({
contacts={contacts} contacts={contacts}
config={config ?? null} config={config ?? null}
messageId={selectedPath.messageId} messageId={selectedPath.messageId}
packetId={selectedPath.packetId}
isOutgoingChan={selectedPath.isOutgoingChan} isOutgoingChan={selectedPath.isOutgoingChan}
isResendable={isSelectedMessageResendable} isResendable={isSelectedMessageResendable}
onResend={onResendChannelMessage} onResend={onResendChannelMessage}
onAnalyzePacket={
selectedPath.packetId != null
? () => {
const message = messages.find((entry) => entry.id === selectedPath.messageId);
if (message) {
void handleAnalyzePacket(message);
}
}
: undefined
}
/>
)}
{packetInspectorSource && (
<RawPacketInspectorDialog
open={packetInspectorSource !== null}
onOpenChange={(isOpen) => !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}
/> />
)} )}
</div> </div>
+11
View File
@@ -29,9 +29,11 @@ interface PathModalProps {
contacts: Contact[]; contacts: Contact[];
config: RadioConfig | null; config: RadioConfig | null;
messageId?: number; messageId?: number;
packetId?: number | null;
isOutgoingChan?: boolean; isOutgoingChan?: boolean;
isResendable?: boolean; isResendable?: boolean;
onResend?: (messageId: number, newTimestamp?: boolean) => void; onResend?: (messageId: number, newTimestamp?: boolean) => void;
onAnalyzePacket?: () => void;
} }
export function PathModal({ export function PathModal({
@@ -42,14 +44,17 @@ export function PathModal({
contacts, contacts,
config, config,
messageId, messageId,
packetId,
isOutgoingChan, isOutgoingChan,
isResendable, isResendable,
onResend, onResend,
onAnalyzePacket,
}: PathModalProps) { }: PathModalProps) {
const { distanceUnit } = useDistanceUnit(); const { distanceUnit } = useDistanceUnit();
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set()); const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend; const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
const hasPaths = paths.length > 0; const hasPaths = paths.length > 0;
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
// Resolve all paths // Resolve all paths
const resolvedPaths = hasPaths const resolvedPaths = hasPaths
@@ -90,6 +95,12 @@ export function PathModal({
{hasPaths && ( {hasPaths && (
<div className="flex-1 overflow-y-auto py-2 space-y-4"> <div className="flex-1 overflow-y-auto py-2 space-y-4">
{showAnalyzePacket ? (
<Button type="button" variant="outline" className="w-full" onClick={onAnalyzePacket}>
Analyze Packet
</Button>
) : null}
{/* Raw path summary */} {/* Raw path summary */}
<div className="text-sm"> <div className="text-sm">
{paths.map((p, index) => { {paths.map((p, index) => {
+165 -12
View File
@@ -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 { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types'; import type { Channel, RawPacket } from '../types';
@@ -18,6 +18,33 @@ interface RawPacketDetailModalProps {
onClose: () => void; 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 { interface RawPacketInspectionPanelProps {
packet: RawPacket; packet: RawPacket;
channels: Channel[]; 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({ function FieldBox({
field, field,
palette, 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 = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
} else if (source.kind === 'paste') {
body = (
<>
<div className="border-b border-border px-4 py-3 pr-14">
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
Packet Hex
</label>
<textarea
id="raw-packet-input"
value={packetInput}
onChange={(event) => setPacketInput(event.target.value)}
placeholder="Paste raw packet hex here..."
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
spellCheck={false}
/>
{packetInputError ? (
<div className="text-sm text-destructive">{packetInputError}</div>
) : null}
</div>
</div>
{analyzedPacket ? (
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
) : (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
Paste a packet above to inspect it.
</div>
)}
</>
);
} else if (source.kind === 'loading') {
body = (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
{source.message}
</div>
);
} else {
body = (
<div className="flex flex-1 items-center justify-center p-6">
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
{source.message}
</div>
</div>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{description}</DialogDescription>
</DialogHeader>
{notice ? (
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
{notice}
</div>
</div>
) : null}
{body}
</DialogContent>
</Dialog>
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) { export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
if (!packet) { if (!packet) {
return null; return null;
} }
return ( return (
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}> <RawPacketInspectorDialog
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0"> open={packet !== null}
<DialogHeader className="border-b border-border px-5 py-3"> onOpenChange={(isOpen) => !isOpen && onClose()}
<DialogTitle>Packet Details</DialogTitle> channels={channels}
<DialogDescription className="sr-only"> source={{ kind: 'packet', packet }}
Detailed byte and field breakdown for the selected raw packet. title="Packet Details"
</DialogDescription> description="Detailed byte and field breakdown for the selected raw packet."
</DialogHeader> />
<RawPacketInspectionPanel packet={packet} channels={channels} />
</DialogContent>
</Dialog>
); );
} }
+19 -90
View File
@@ -2,9 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { RawPacketList } from './RawPacketList'; import { RawPacketList } from './RawPacketList';
import { RawPacketDetailModal, RawPacketInspectionPanel } from './RawPacketDetailModal'; import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
import type { Channel, Contact, RawPacket } from '../types'; import type { Channel, Contact, RawPacket } from '../types';
import { import {
RAW_PACKET_STATS_WINDOWS, RAW_PACKET_STATS_WINDOWS,
@@ -373,36 +372,6 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
); );
} }
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,
};
}
export function RawPacketFeedView({ export function RawPacketFeedView({
packets, packets,
rawPacketStatsSession, rawPacketStatsSession,
@@ -418,7 +387,6 @@ export function RawPacketFeedView({
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null); const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false); const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
const [packetInput, setPacketInput] = useState('');
useEffect(() => { useEffect(() => {
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
@@ -452,27 +420,6 @@ export function RawPacketFeedView({
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)), () => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.newestNeighbors] [contacts, stats.newestNeighbors]
); );
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]
);
const handleAnalyzeModalChange = (isOpen: boolean) => {
setAnalyzeModalOpen(isOpen);
if (isOpen) {
return;
}
setPacketInput('');
};
return ( return (
<> <>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5"> <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
@@ -664,45 +611,27 @@ export function RawPacketFeedView({
</aside> </aside>
</div> </div>
<RawPacketDetailModal <RawPacketInspectorDialog
packet={selectedPacket} open={selectedPacket !== null}
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
channels={channels} channels={channels}
onClose={() => setSelectedPacket(null)} source={
selectedPacket
? { kind: 'packet', packet: selectedPacket }
: { kind: 'loading', message: 'Loading packet...' }
}
title="Packet Details"
description="Detailed byte and field breakdown for the selected raw packet."
/> />
<Dialog open={analyzeModalOpen} onOpenChange={handleAnalyzeModalChange}> <RawPacketInspectorDialog
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0"> open={analyzeModalOpen}
<DialogHeader className="sr-only"> onOpenChange={setAnalyzeModalOpen}
<DialogTitle>Analyze Packet</DialogTitle> channels={channels}
<DialogDescription>Paste and inspect a raw packet hex string.</DialogDescription> source={{ kind: 'paste' }}
</DialogHeader> title="Analyze Packet"
<div className="border-b border-border px-4 py-3 pr-14"> description="Paste and inspect a raw packet hex string."
<div className="flex flex-col gap-3"> />
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
Packet Hex
</label>
<textarea
id="raw-packet-input"
value={packetInput}
onChange={(event) => setPacketInput(event.target.value)}
placeholder="Paste raw packet hex here..."
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
spellCheck={false}
/>
{packetInputError ? (
<div className="text-sm text-destructive">{packetInputError}</div>
) : null}
</div>
</div>
{analyzedPacket ? (
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
) : (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
Paste a packet above and click Analyze to inspect it.
</div>
)}
</DialogContent>
</Dialog>
</> </>
); );
} }
@@ -173,8 +173,8 @@ export function SettingsDatabaseSection({
Deletes archival copies of raw packet bytes for messages that are already decrypted and Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '} visible in your chat history.{' '}
<em className="text-muted-foreground/80"> <em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality, nor impact your This will not affect any displayed messages or your ability to do historical decryption,
ability to do historical decryption. but it will remove packet-analysis availability for those historical messages.
</em>{' '} </em>{' '}
The raw bytes are only useful for manual packet analysis. The raw bytes are only useful for manual packet analysis.
</p> </p>
+55 -16
View File
@@ -86,7 +86,12 @@ export class ConversationMessageCache {
return true; return true;
} }
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void { updateAck(
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
): void {
for (const entry of this.cache.values()) { for (const entry of this.cache.values()) {
const index = entry.messages.findIndex((message) => message.id === messageId); const index = entry.messages.findIndex((message) => message.id === messageId);
if (index < 0) continue; if (index < 0) continue;
@@ -96,6 +101,7 @@ export class ConversationMessageCache {
...current, ...current,
acked: Math.max(current.acked, ackCount), acked: Math.max(current.acked, ackCount),
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }), ...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
...(packetId !== undefined && { packet_id: packetId }),
}; };
entry.messages = updated; entry.messages = updated;
return; return;
@@ -146,12 +152,16 @@ export function reconcileConversationMessages(
current: Message[], current: Message[],
fetched: Message[] fetched: Message[]
): Message[] | null { ): Message[] | null {
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>(); const currentById = new Map<
number,
{ acked: number; pathsLen: number; text: string; packetId: number | null | undefined }
>();
for (const message of current) { for (const message of current) {
currentById.set(message.id, { currentById.set(message.id, {
acked: message.acked, acked: message.acked,
pathsLen: message.paths?.length ?? 0, pathsLen: message.paths?.length ?? 0,
text: message.text, text: message.text,
packetId: message.packet_id,
}); });
} }
@@ -162,7 +172,8 @@ export function reconcileConversationMessages(
!currentMessage || !currentMessage ||
currentMessage.acked !== message.acked || currentMessage.acked !== message.acked ||
currentMessage.pathsLen !== (message.paths?.length ?? 0) || currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
currentMessage.text !== message.text currentMessage.text !== message.text ||
currentMessage.packetId !== message.packet_id
) { ) {
needsUpdate = true; needsUpdate = true;
break; break;
@@ -180,17 +191,20 @@ export const conversationMessageCache = new ConversationMessageCache();
interface PendingAckUpdate { interface PendingAckUpdate {
ackCount: number; ackCount: number;
paths?: MessagePath[]; paths?: MessagePath[];
packetId?: number | null;
} }
export function mergePendingAck( export function mergePendingAck(
existing: PendingAckUpdate | undefined, existing: PendingAckUpdate | undefined,
ackCount: number, ackCount: number,
paths?: MessagePath[] paths?: MessagePath[],
packetId?: number | null
): PendingAckUpdate { ): PendingAckUpdate {
if (!existing) { if (!existing) {
return { return {
ackCount, ackCount,
...(paths !== undefined && { paths }), ...(paths !== undefined && { paths }),
...(packetId !== undefined && { packetId }),
}; };
} }
@@ -199,6 +213,9 @@ export function mergePendingAck(
ackCount, ackCount,
...(paths !== undefined && { paths }), ...(paths !== undefined && { paths }),
...(paths === undefined && existing.paths !== undefined && { paths: existing.paths }), ...(paths === undefined && existing.paths !== undefined && { paths: existing.paths }),
...(packetId !== undefined && { packetId }),
...(packetId === undefined &&
existing.packetId !== undefined && { packetId: existing.packetId }),
}; };
} }
@@ -206,16 +223,31 @@ export function mergePendingAck(
return existing; return existing;
} }
const packetIdChanged = packetId !== undefined && packetId !== existing.packetId;
if (paths === undefined) { if (paths === undefined) {
return existing; if (!packetIdChanged) {
return existing;
}
return {
...existing,
packetId,
};
} }
const existingPathCount = existing.paths?.length ?? -1; const existingPathCount = existing.paths?.length ?? -1;
if (paths.length >= existingPathCount) { if (paths.length >= existingPathCount) {
return { ackCount, paths }; return { ackCount, paths, ...(packetId !== undefined && { packetId }) };
} }
return existing; if (!packetIdChanged) {
return existing;
}
return {
...existing,
packetId,
};
} }
interface UseConversationMessagesResult { interface UseConversationMessagesResult {
@@ -230,7 +262,12 @@ interface UseConversationMessagesResult {
jumpToBottom: () => void; jumpToBottom: () => void;
reloadCurrentConversation: () => void; reloadCurrentConversation: () => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean }; observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; receiveMessageAck: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
reconcileOnReconnect: () => void; reconcileOnReconnect: () => void;
renameConversationMessages: (oldId: string, newId: string) => void; renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void; removeConversationMessages: (conversationId: string) => void;
@@ -291,9 +328,9 @@ export function useConversationMessages(
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map()); const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
const setPendingAck = useCallback( const setPendingAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => { (messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
const existing = pendingAcksRef.current.get(messageId); const existing = pendingAcksRef.current.get(messageId);
const merged = mergePendingAck(existing, ackCount, paths); const merged = mergePendingAck(existing, ackCount, paths, packetId);
// Update insertion order so most recent updates remain in the buffer longest. // Update insertion order so most recent updates remain in the buffer longest.
pendingAcksRef.current.delete(messageId); pendingAcksRef.current.delete(messageId);
@@ -319,6 +356,7 @@ export function useConversationMessages(
...msg, ...msg,
acked: Math.max(msg.acked, pending.ackCount), acked: Math.max(msg.acked, pending.ackCount),
...(pending.paths !== undefined && { paths: pending.paths }), ...(pending.paths !== undefined && { paths: pending.paths }),
...(pending.packetId !== undefined && { packet_id: pending.packetId }),
}; };
}, []); }, []);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
@@ -782,10 +820,10 @@ export function useConversationMessages(
// Update a message's ack count and paths // Update a message's ack count and paths
const updateMessageAck = useCallback( const updateMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => { (messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
const hasMessageLoaded = messagesRef.current.some((m) => m.id === messageId); const hasMessageLoaded = messagesRef.current.some((m) => m.id === messageId);
if (!hasMessageLoaded) { if (!hasMessageLoaded) {
setPendingAck(messageId, ackCount, paths); setPendingAck(messageId, ackCount, paths, packetId);
return; return;
} }
@@ -807,10 +845,11 @@ export function useConversationMessages(
...current, ...current,
acked: nextAck, acked: nextAck,
...(paths !== undefined && { paths: nextPaths }), ...(paths !== undefined && { paths: nextPaths }),
...(packetId !== undefined && { packet_id: packetId }),
}; };
return updated; return updated;
} }
setPendingAck(messageId, ackCount, paths); setPendingAck(messageId, ackCount, paths, packetId);
return prev; return prev;
}); });
}, },
@@ -818,9 +857,9 @@ export function useConversationMessages(
); );
const receiveMessageAck = useCallback( const receiveMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => { (messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
updateMessageAck(messageId, ackCount, paths); updateMessageAck(messageId, ackCount, paths, packetId);
conversationMessageCache.updateAck(messageId, ackCount, paths); conversationMessageCache.updateAck(messageId, ackCount, paths, packetId);
}, },
[updateMessageAck] [updateMessageAck]
); );
+13 -3
View File
@@ -48,7 +48,12 @@ interface UseRealtimeAppStateArgs {
setActiveConversation: (conv: Conversation | null) => void; setActiveConversation: (conv: Conversation | null) => void;
renameConversationMessages: (oldId: string, newId: string) => void; renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void; removeConversationMessages: (conversationId: string) => void;
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; receiveMessageAck: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
notifyIncomingMessage?: (msg: Message) => void; notifyIncomingMessage?: (msg: Message) => void;
recordRawPacketObservation?: (packet: RawPacket) => void; recordRawPacketObservation?: (packet: RawPacket) => void;
maxRawPackets?: number; maxRawPackets?: number;
@@ -246,8 +251,13 @@ export function useRealtimeAppState({
recordRawPacketObservation?.(packet); recordRawPacketObservation?.(packet);
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets)); setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
}, },
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => { onMessageAcked: (
receiveMessageAck(messageId, ackCount, paths); messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => {
receiveMessageAck(messageId, ackCount, paths, packetId);
}, },
}), }),
[ [
+4
View File
@@ -557,6 +557,10 @@ describe('SettingsModal', () => {
renderModal(); renderModal();
openDatabaseSection(); openDatabaseSection();
expect(
screen.getByText(/remove packet-analysis availability for those historical messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' })); fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
await waitFor(() => { await waitFor(() => {
@@ -136,7 +136,7 @@ describe('useWebSocket dispatch', () => {
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact); expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
}); });
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => { it('routes message_acked to onMessageAcked with (messageId, ackCount, paths, packetId)', () => {
const onMessageAcked = vi.fn(); const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked })); renderHook(() => useWebSocket({ onMessageAcked }));
@@ -144,7 +144,7 @@ describe('useWebSocket dispatch', () => {
fireMessage({ type, data }); fireMessage({ type, data });
expect(onMessageAcked).toHaveBeenCalledOnce(); expect(onMessageAcked).toHaveBeenCalledOnce();
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined); expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined, undefined);
}); });
it('routes message_acked with paths', () => { it('routes message_acked with paths', () => {
@@ -154,7 +154,16 @@ describe('useWebSocket dispatch', () => {
const paths = [{ path: 'aabb', received_at: 1700000000 }]; const paths = [{ path: 'aabb', received_at: 1700000000 }];
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } }); fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } });
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths); expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths, undefined);
});
it('routes message_acked with packet_id', () => {
const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked }));
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, packet_id: 99 } });
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, undefined, 99);
}); });
it('routes error event to onError', () => { it('routes error event to onError', () => {
+1
View File
@@ -267,6 +267,7 @@ export interface Message {
acked: number; acked: number;
sender_name: string | null; sender_name: string | null;
channel_name?: string | null; channel_name?: string | null;
packet_id?: number | null;
} }
export interface MessagesAroundResponse { export interface MessagesAroundResponse {
+13 -2
View File
@@ -21,7 +21,12 @@ export interface UseWebSocketOptions {
onChannel?: (channel: Channel) => void; onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void; onChannelDeleted?: (key: string) => void;
onRawPacket?: (packet: RawPacket) => void; onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; onMessageAcked?: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
onError?: (error: ErrorEvent) => void; onError?: (error: ErrorEvent) => void;
onSuccess?: (success: SuccessEvent) => void; onSuccess?: (success: SuccessEvent) => void;
onReconnect?: () => void; onReconnect?: () => void;
@@ -128,8 +133,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
message_id: number; message_id: number;
ack_count: number; ack_count: number;
paths?: MessagePath[]; paths?: MessagePath[];
packet_id?: number | null;
}; };
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths); handlers.onMessageAcked?.(
ackData.message_id,
ackData.ack_count,
ackData.paths,
ackData.packet_id
);
break; break;
} }
case 'error': case 'error':
+1
View File
@@ -4,6 +4,7 @@ export interface MessageAckedPayload {
message_id: number; message_id: number;
ack_count: number; ack_count: number;
paths?: MessagePath[]; paths?: MessagePath[];
packet_id?: number | null;
} }
export interface ContactDeletedPayload { export interface ContactDeletedPayload {
+4 -1
View File
@@ -1131,7 +1131,7 @@ class TestMessageAckedBroadcastShape:
# Frontend MessageAckedEvent keys (from useWebSocket.ts:113-117) # Frontend MessageAckedEvent keys (from useWebSocket.ts:113-117)
# The 'paths' key is optional in the TypeScript interface # The 'paths' key is optional in the TypeScript interface
REQUIRED_KEYS = {"message_id", "ack_count"} REQUIRED_KEYS = {"message_id", "ack_count"}
OPTIONAL_KEYS = {"paths"} OPTIONAL_KEYS = {"paths", "packet_id"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_outgoing_echo_broadcast_shape(self, test_db, captured_broadcasts): async def test_outgoing_echo_broadcast_shape(self, test_db, captured_broadcasts):
@@ -1177,6 +1177,7 @@ class TestMessageAckedBroadcastShape:
assert isinstance(payload["ack_count"], int) assert isinstance(payload["ack_count"], int)
assert payload["message_id"] == msg_id assert payload["message_id"] == msg_id
assert payload["ack_count"] == 1 assert payload["ack_count"] == 1
assert payload["packet_id"] == pkt_id
# paths should be a list of dicts with path and received_at keys # paths should be a list of dicts with path and received_at keys
assert isinstance(payload["paths"], list) assert isinstance(payload["paths"], list)
@@ -1228,6 +1229,7 @@ class TestMessageAckedBroadcastShape:
assert payload_keys >= self.REQUIRED_KEYS assert payload_keys >= self.REQUIRED_KEYS
assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS) assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS)
assert payload["ack_count"] == 0 # Not outgoing, no ack increment assert payload["ack_count"] == 0 # Not outgoing, no ack increment
assert payload["packet_id"] == pkt1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dm_echo_broadcast_shape(self, test_db, captured_broadcasts): async def test_dm_echo_broadcast_shape(self, test_db, captured_broadcasts):
@@ -1283,3 +1285,4 @@ class TestMessageAckedBroadcastShape:
assert isinstance(payload["message_id"], int) assert isinstance(payload["message_id"], int)
assert isinstance(payload["ack_count"], int) assert isinstance(payload["ack_count"], int)
assert payload["ack_count"] == 0 # Outgoing DM duplicates no longer count as delivery assert payload["ack_count"] == 0 # Outgoing DM duplicates no longer count as delivery
assert payload["packet_id"] == pkt1
+1
View File
@@ -384,6 +384,7 @@ class TestContactMessageCLIFiltering:
"acked", "acked",
"sender_name", "sender_name",
"channel_name", "channel_name",
"packet_id",
} }
with patch("app.event_handlers.broadcast_event") as mock_broadcast: with patch("app.event_handlers.broadcast_event") as mock_broadcast:
+42
View File
@@ -56,6 +56,48 @@ class TestUndecryptedCount:
assert response.json()["count"] == 3 assert response.json()["count"] == 3
class TestGetRawPacket:
"""Test GET /api/packets/{id}."""
@pytest.mark.asyncio
async def test_returns_404_when_missing(self, test_db, client):
response = await client.get("/api/packets/999999")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_returns_linked_packet_details(self, test_db, client):
channel_key = "DEADBEEF" * 4
await ChannelRepository.upsert(key=channel_key, name="#ops", is_hashtag=False)
packet_id, _ = await RawPacketRepository.create(b"\x09\x00test-packet", 1700000000)
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="Alice: hello",
conversation_key=channel_key,
sender_timestamp=1700000000,
received_at=1700000000,
sender_name="Alice",
)
assert msg_id is not None
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
response = await client.get(f"/api/packets/{packet_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == packet_id
assert data["timestamp"] == 1700000000
assert data["data"] == "0900746573742d7061636b6574"
assert data["decrypted"] is True
assert data["decrypted_info"] == {
"channel_name": "#ops",
"sender": "Alice",
"channel_key": channel_key,
"contact_key": None,
}
class TestDecryptHistoricalPackets: class TestDecryptHistoricalPackets:
"""Test POST /api/packets/decrypt/historical.""" """Test POST /api/packets/decrypt/historical."""