From b91b2d5d7b270af1d485c13fc7ac010758eb3c64 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 27 Feb 2026 13:38:53 -0800 Subject: [PATCH] Contact info pane --- AGENTS.md | 5 +- app/AGENTS.md | 9 +- app/database.py | 32 +- app/event_handlers.py | 14 +- app/migrations.py | 364 ++++++++++++++++++ app/models.py | 55 ++- app/packet_processor.py | 33 +- app/radio_sync.py | 6 + app/repository.py | 233 ++++++++--- app/routers/contacts.py | 115 +++++- app/routers/settings.py | 14 +- frontend/AGENTS.md | 1 + frontend/src/App.tsx | 33 ++ frontend/src/api.ts | 11 +- frontend/src/components/ChatHeader.tsx | 29 +- frontend/src/components/ContactAvatar.tsx | 11 +- frontend/src/components/ContactInfoPane.tsx | 338 ++++++++++++++++ frontend/src/components/MessageList.tsx | 17 +- .../src/components/PacketVisualizer3D.tsx | 10 +- frontend/src/test/appStartupHash.test.tsx | 1 + frontend/src/test/integration.test.ts | 1 + frontend/src/test/pathUtils.test.ts | 1 + frontend/src/test/sidebar.test.tsx | 1 + frontend/src/test/urlHash.test.ts | 3 + frontend/src/types.ts | 40 +- tests/test_contacts_router.py | 167 +++++++- tests/test_migrations.py | 32 +- tests/test_repository.py | 210 ++++++++-- 28 files changed, 1624 insertions(+), 162 deletions(-) create mode 100644 frontend/src/components/ContactInfoPane.tsx diff --git a/AGENTS.md b/AGENTS.md index 9ae5a74..03489ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,9 +268,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected | | POST | `/api/radio/reconnect` | Manual radio reconnection | | GET | `/api/contacts` | List contacts | -| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all repeaters | +| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts | | GET | `/api/contacts/{key}` | Get contact by public key or prefix | -| GET | `/api/contacts/{key}/advert-paths` | List recent unique advert paths for one repeater | +| GET | `/api/contacts/{key}/detail` | Comprehensive contact profile (stats, name history, paths) | +| GET | `/api/contacts/{key}/advert-paths` | List recent unique advert paths for a contact | | POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) | | DELETE | `/api/contacts/{key}` | Delete contact | | POST | `/api/contacts/sync` | Pull from radio | diff --git a/app/AGENTS.md b/app/AGENTS.md index 570d139..125a4d7 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -114,7 +114,10 @@ app/ ### Contacts - `GET /contacts` +- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts - `GET /contacts/{public_key}` +- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters) +- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact - `POST /contacts` - `DELETE /contacts/{public_key}` - `POST /contacts/sync` @@ -176,10 +179,12 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`. ## Data Model Notes Main tables: -- `contacts` +- `contacts` (includes `first_seen` for contact age tracking) - `channels` -- `messages` +- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution) - `raw_packets` +- `contact_advert_paths` (recent unique advertisement paths per contact) +- `contact_name_history` (tracks name changes over time) - `app_settings` `app_settings` fields in active model: diff --git a/app/database.py b/app/database.py index bb754fb..42436a3 100644 --- a/app/database.py +++ b/app/database.py @@ -20,7 +20,8 @@ CREATE TABLE IF NOT EXISTS contacts ( lon REAL, last_seen INTEGER, on_radio INTEGER DEFAULT 0, - last_contacted INTEGER + last_contacted INTEGER, + first_seen INTEGER ); CREATE TABLE IF NOT EXISTS channels ( @@ -41,7 +42,9 @@ CREATE TABLE IF NOT EXISTS messages ( txt_type INTEGER DEFAULT 0, signature TEXT, outgoing INTEGER DEFAULT 0, - acked INTEGER DEFAULT 0 + acked INTEGER DEFAULT 0, + sender_name TEXT, + sender_key TEXT -- Deduplication: identical text + timestamp in the same conversation is treated as a -- mesh echo/repeat. Second-precision timestamps mean two intentional identical messages -- within the same second would collide, but this is not feasible in practice — LoRa @@ -59,16 +62,26 @@ CREATE TABLE IF NOT EXISTS raw_packets ( FOREIGN KEY (message_id) REFERENCES messages(id) ); -CREATE TABLE IF NOT EXISTS repeater_advert_paths ( +CREATE TABLE IF NOT EXISTS contact_advert_paths ( id INTEGER PRIMARY KEY AUTOINCREMENT, - repeater_key TEXT NOT NULL, + public_key TEXT NOT NULL, path_hex TEXT NOT NULL, path_len INTEGER NOT NULL, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, heard_count INTEGER NOT NULL DEFAULT 1, - UNIQUE(repeater_key, path_hex), - FOREIGN KEY (repeater_key) REFERENCES contacts(public_key) + UNIQUE(public_key, path_hex), + FOREIGN KEY (public_key) REFERENCES contacts(public_key) +); + +CREATE TABLE IF NOT EXISTS contact_name_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + name TEXT NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + UNIQUE(public_key, name), + FOREIGN KEY (public_key) REFERENCES contacts(public_key) ); CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key); @@ -78,8 +91,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash); CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio); -CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent - ON repeater_advert_paths(repeater_key, last_seen DESC); +-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column) +CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent + ON contact_advert_paths(public_key, last_seen DESC); +CREATE INDEX IF NOT EXISTS idx_contact_name_history_key + ON contact_name_history(public_key, last_seen DESC); """ diff --git a/app/event_handlers.py b/app/event_handlers.py index 86323b9..cb8ea8c 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -7,7 +7,12 @@ from meshcore import EventType from app.models import CONTACT_TYPE_REPEATER, Contact from app.packet_processor import process_raw_packet -from app.repository import AmbiguousPublicKeyPrefixError, ContactRepository, MessageRepository +from app.repository import ( + AmbiguousPublicKeyPrefixError, + ContactNameHistoryRepository, + ContactRepository, + MessageRepository, +) from app.websocket import broadcast_event if TYPE_CHECKING: @@ -251,6 +256,13 @@ async def on_new_contact(event: "Event") -> None: } await ContactRepository.upsert(contact_data) + # Record name history if contact has a name + adv_name = payload.get("adv_name") + if adv_name: + await ContactNameHistoryRepository.record_name( + public_key.lower(), adv_name, int(time.time()) + ) + # Read back from DB so the broadcast includes all fields (last_contacted, # last_read_at, etc.) matching the REST Contact shape exactly. db_contact = await ContactRepository.get_by_key(public_key) diff --git a/app/migrations.py b/app/migrations.py index 0bd2af7..6fa0240 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -191,6 +191,41 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 22) applied += 1 + # Migration 23: Add first_seen column to contacts + if version < 23: + logger.info("Applying migration 23: add first_seen column to contacts") + await _migrate_023_add_first_seen(conn) + await set_version(conn, 23) + applied += 1 + + # Migration 24: Create contact_name_history table + if version < 24: + logger.info("Applying migration 24: create contact_name_history table") + await _migrate_024_create_contact_name_history(conn) + await set_version(conn, 24) + applied += 1 + + # Migration 25: Add sender_name and sender_key to messages + if version < 25: + logger.info("Applying migration 25: add sender_name and sender_key to messages") + await _migrate_025_add_sender_columns(conn) + await set_version(conn, 25) + applied += 1 + + # Migration 26: Rename repeater_advert_paths to contact_advert_paths + if version < 26: + logger.info("Applying migration 26: rename repeater_advert_paths to contact_advert_paths") + await _migrate_026_rename_advert_paths_table(conn) + await set_version(conn, 26) + applied += 1 + + # Migration 27: Backfill first_seen from advert paths + if version < 27: + logger.info("Applying migration 27: backfill first_seen from advert paths") + await _migrate_027_backfill_first_seen_from_advert_paths(conn) + await set_version(conn, 27) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -1318,3 +1353,332 @@ async def _migrate_022_add_repeater_advert_paths(conn: aiosqlite.Connection) -> ) await conn.commit() logger.debug("Ensured repeater_advert_paths table and indexes exist") + + +async def _migrate_023_add_first_seen(conn: aiosqlite.Connection) -> None: + """ + Add first_seen column to contacts table. + + Backfill strategy: + 1. Set first_seen = last_seen for all contacts (baseline). + 2. For contacts with PRIV messages, set first_seen = MIN(messages.received_at) + if that timestamp is earlier. + """ + # Guard: skip if contacts table doesn't exist (e.g. partial test schemas) + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'" + ) + if not await cursor.fetchone(): + return + + try: + await conn.execute("ALTER TABLE contacts ADD COLUMN first_seen INTEGER") + logger.debug("Added first_seen to contacts table") + except aiosqlite.OperationalError as e: + if "duplicate column name" in str(e).lower(): + logger.debug("contacts.first_seen already exists, skipping") + else: + raise + + # Baseline: set first_seen = last_seen for all contacts + # Check if last_seen column exists (should in production, may not in minimal test schemas) + cursor = await conn.execute("PRAGMA table_info(contacts)") + columns = {row[1] for row in await cursor.fetchall()} + if "last_seen" in columns: + await conn.execute("UPDATE contacts SET first_seen = last_seen WHERE first_seen IS NULL") + + # Refine: for contacts with PRIV messages, use earliest message timestamp if earlier + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='messages'" + ) + if await cursor.fetchone(): + await conn.execute( + """ + UPDATE contacts SET first_seen = ( + SELECT MIN(m.received_at) FROM messages m + WHERE m.type = 'PRIV' AND m.conversation_key = contacts.public_key + ) + WHERE EXISTS ( + SELECT 1 FROM messages m + WHERE m.type = 'PRIV' AND m.conversation_key = contacts.public_key + AND m.received_at < COALESCE(contacts.first_seen, 9999999999) + ) + """ + ) + + await conn.commit() + logger.debug("Added and backfilled first_seen column") + + +async def _migrate_024_create_contact_name_history(conn: aiosqlite.Connection) -> None: + """ + Create contact_name_history table and seed with current contact names. + """ + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS contact_name_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + name TEXT NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + UNIQUE(public_key, name), + FOREIGN KEY (public_key) REFERENCES contacts(public_key) + ) + """ + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_contact_name_history_key " + "ON contact_name_history(public_key, last_seen DESC)" + ) + + # Seed: one row per contact from current data (skip if contacts table doesn't exist + # or lacks needed columns) + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'" + ) + if await cursor.fetchone(): + cursor = await conn.execute("PRAGMA table_info(contacts)") + cols = {row[1] for row in await cursor.fetchall()} + if "name" in cols and "public_key" in cols: + first_seen_expr = "first_seen" if "first_seen" in cols else "0" + last_seen_expr = "last_seen" if "last_seen" in cols else "0" + await conn.execute( + f""" + INSERT OR IGNORE INTO contact_name_history (public_key, name, first_seen, last_seen) + SELECT public_key, name, + COALESCE({first_seen_expr}, {last_seen_expr}, 0), + COALESCE({last_seen_expr}, 0) + FROM contacts + WHERE name IS NOT NULL AND name != '' + """ + ) + + await conn.commit() + logger.debug("Created contact_name_history table and seeded from contacts") + + +async def _migrate_025_add_sender_columns(conn: aiosqlite.Connection) -> None: + """ + Add sender_name and sender_key columns to messages table. + + Backfill: + - sender_name for CHAN messages: extract from "Name: message" format + - sender_key for CHAN messages: match name to contact (skip ambiguous) + - sender_key for incoming PRIV messages: set to conversation_key + """ + # Guard: skip if messages table doesn't exist + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='messages'" + ) + if not await cursor.fetchone(): + return + + for column in ["sender_name", "sender_key"]: + try: + await conn.execute(f"ALTER TABLE messages ADD COLUMN {column} TEXT") + logger.debug("Added %s to messages table", column) + except aiosqlite.OperationalError as e: + if "duplicate column name" in str(e).lower(): + logger.debug("messages.%s already exists, skipping", column) + else: + raise + + # Check which columns the messages table has (may be minimal in test environments) + cursor = await conn.execute("PRAGMA table_info(messages)") + msg_cols = {row[1] for row in await cursor.fetchall()} + + # Only backfill if the required columns exist + if "type" in msg_cols and "text" in msg_cols: + # Count messages to backfill for progress reporting + cursor = await conn.execute( + "SELECT COUNT(*) FROM messages WHERE type = 'CHAN' AND sender_name IS NULL" + ) + row = await cursor.fetchone() + chan_count = row[0] if row else 0 + if chan_count > 0: + logger.info("Backfilling sender_name for %d channel messages...", chan_count) + + # Backfill sender_name for CHAN messages from "Name: message" format + # Only extract if colon position is valid (> 1 and < 51, i.e. name is 1-50 chars) + cursor = await conn.execute( + """ + UPDATE messages SET sender_name = SUBSTR(text, 1, INSTR(text, ': ') - 1) + WHERE type = 'CHAN' AND sender_name IS NULL + AND INSTR(text, ': ') > 1 AND INSTR(text, ': ') < 52 + """ + ) + if cursor.rowcount > 0: + logger.info("Backfilled sender_name for %d channel messages", cursor.rowcount) + + # Backfill sender_key for incoming PRIV messages + if "outgoing" in msg_cols and "conversation_key" in msg_cols: + cursor = await conn.execute( + """ + UPDATE messages SET sender_key = conversation_key + WHERE type = 'PRIV' AND outgoing = 0 AND sender_key IS NULL + """ + ) + if cursor.rowcount > 0: + logger.info("Backfilled sender_key for %d DM messages", cursor.rowcount) + + # Backfill sender_key for CHAN messages: match sender_name to contacts + # Build name->key map, skip ambiguous names (multiple contacts with same name) + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'" + ) + if await cursor.fetchone(): + cursor = await conn.execute( + "SELECT public_key, name FROM contacts WHERE name IS NOT NULL AND name != ''" + ) + rows = await cursor.fetchall() + + name_to_keys: dict[str, list[str]] = {} + for row in rows: + name = row["name"] + key = row["public_key"] + if name not in name_to_keys: + name_to_keys[name] = [] + name_to_keys[name].append(key) + + # Only use unambiguous names (single contact per name) + unambiguous = {n: ks[0] for n, ks in name_to_keys.items() if len(ks) == 1} + if unambiguous: + logger.info( + "Matching sender_key for %d unique contact names...", + len(unambiguous), + ) + # Use a temp table for a single bulk UPDATE instead of N individual queries + await conn.execute( + "CREATE TEMP TABLE _name_key_map (name TEXT PRIMARY KEY, public_key TEXT NOT NULL)" + ) + await conn.executemany( + "INSERT INTO _name_key_map (name, public_key) VALUES (?, ?)", + list(unambiguous.items()), + ) + cursor = await conn.execute( + """ + UPDATE messages SET sender_key = ( + SELECT public_key FROM _name_key_map WHERE _name_key_map.name = messages.sender_name + ) + WHERE type = 'CHAN' AND sender_key IS NULL + AND sender_name IN (SELECT name FROM _name_key_map) + """ + ) + updated = cursor.rowcount + await conn.execute("DROP TABLE _name_key_map") + if updated > 0: + logger.info("Backfilled sender_key for %d channel messages", updated) + + # Create index on sender_key for per-contact channel message counts + await conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key)") + + await conn.commit() + logger.debug("Added sender_name and sender_key columns with backfill") + + +async def _migrate_026_rename_advert_paths_table(conn: aiosqlite.Connection) -> None: + """ + Rename repeater_advert_paths to contact_advert_paths with column + repeater_key -> public_key. + + Uses table rebuild since ALTER TABLE RENAME COLUMN may not be available + in older SQLite versions. + """ + # Check if old table exists + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='repeater_advert_paths'" + ) + if not await cursor.fetchone(): + # Already renamed or doesn't exist — ensure new table exists + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS contact_advert_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + path_hex TEXT NOT NULL, + path_len INTEGER NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + heard_count INTEGER NOT NULL DEFAULT 1, + UNIQUE(public_key, path_hex), + FOREIGN KEY (public_key) REFERENCES contacts(public_key) + ) + """ + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent " + "ON contact_advert_paths(public_key, last_seen DESC)" + ) + await conn.commit() + logger.debug("contact_advert_paths already exists or old table missing, skipping rename") + return + + # Create new table (IF NOT EXISTS in case SCHEMA already created it) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS contact_advert_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + path_hex TEXT NOT NULL, + path_len INTEGER NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + heard_count INTEGER NOT NULL DEFAULT 1, + UNIQUE(public_key, path_hex), + FOREIGN KEY (public_key) REFERENCES contacts(public_key) + ) + """ + ) + + # Copy data (INSERT OR IGNORE in case of duplicates) + await conn.execute( + """ + INSERT OR IGNORE INTO contact_advert_paths (public_key, path_hex, path_len, first_seen, last_seen, heard_count) + SELECT repeater_key, path_hex, path_len, first_seen, last_seen, heard_count + FROM repeater_advert_paths + """ + ) + + # Drop old table + await conn.execute("DROP TABLE repeater_advert_paths") + + # Create index + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent " + "ON contact_advert_paths(public_key, last_seen DESC)" + ) + + await conn.commit() + logger.info("Renamed repeater_advert_paths to contact_advert_paths") + + +async def _migrate_027_backfill_first_seen_from_advert_paths(conn: aiosqlite.Connection) -> None: + """ + Backfill contacts.first_seen from contact_advert_paths where advert path + first_seen is earlier than the contact's current first_seen. + """ + # Guard: skip if either table doesn't exist + for table in ("contacts", "contact_advert_paths"): + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,) + ) + if not await cursor.fetchone(): + return + + await conn.execute( + """ + UPDATE contacts SET first_seen = ( + SELECT MIN(cap.first_seen) FROM contact_advert_paths cap + WHERE cap.public_key = contacts.public_key + ) + WHERE EXISTS ( + SELECT 1 FROM contact_advert_paths cap + WHERE cap.public_key = contacts.public_key + AND cap.first_seen < COALESCE(contacts.first_seen, 9999999999) + ) + """ + ) + + await conn.commit() + logger.debug("Backfilled first_seen from contact_advert_paths") diff --git a/app/models.py b/app/models.py index 33acd28..72c8534 100644 --- a/app/models.py +++ b/app/models.py @@ -17,6 +17,7 @@ class Contact(BaseModel): on_radio: bool = False last_contacted: int | None = None # Last time we sent/received a message last_read_at: int | None = None # Server-side read state tracking + first_seen: int | None = None def to_radio_dict(self) -> dict: """Convert to the dict format expected by meshcore radio commands. @@ -72,8 +73,8 @@ class CreateContactRequest(BaseModel): CONTACT_TYPE_REPEATER = 2 -class RepeaterAdvertPath(BaseModel): - """A unique advert path observed for a repeater.""" +class ContactAdvertPath(BaseModel): + """A unique advert path observed for a contact.""" path: str = Field(description="Hex-encoded routing path (empty string for direct)") path_len: int = Field(description="Number of hops in the path") @@ -85,15 +86,57 @@ class RepeaterAdvertPath(BaseModel): heard_count: int = Field(description="Number of times this unique path was heard") -class RepeaterAdvertPathSummary(BaseModel): - """Recent unique advertisement paths for a single repeater.""" +class ContactAdvertPathSummary(BaseModel): + """Recent unique advertisement paths for a single contact.""" - repeater_key: str = Field(description="Repeater public key (64-char hex)") - paths: list[RepeaterAdvertPath] = Field( + public_key: str = Field(description="Contact public key (64-char hex)") + paths: list[ContactAdvertPath] = Field( default_factory=list, description="Most recent unique advert paths" ) +class ContactNameHistory(BaseModel): + """A historical name used by a contact.""" + + name: str + first_seen: int + last_seen: int + + +class ContactActiveRoom(BaseModel): + """A channel/room where a contact has been active.""" + + channel_key: str + channel_name: str + message_count: int + + +class NearestRepeater(BaseModel): + """A repeater that has relayed a contact's advertisements.""" + + public_key: str + name: str | None = None + path_len: int + last_seen: int + heard_count: int + + +class ContactDetail(BaseModel): + """Comprehensive contact profile data.""" + + contact: Contact + name_history: list[ContactNameHistory] = Field(default_factory=list) + dm_message_count: int = 0 + channel_message_count: int = 0 + most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list) + advert_paths: list[ContactAdvertPath] = Field(default_factory=list) + advert_frequency: float | None = Field( + default=None, + description="Advert observations per hour (includes multi-path arrivals of same advert)", + ) + nearest_repeaters: list[NearestRepeater] = Field(default_factory=list) + + class Channel(BaseModel): key: str = Field(description="Channel key (32-char hex)") name: str diff --git a/app/packet_processor.py b/app/packet_processor.py index 4e537f4..a59c613 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -31,10 +31,11 @@ from app.keystore import get_private_key, get_public_key, has_private_key from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo from app.repository import ( ChannelRepository, + ContactAdvertPathRepository, + ContactNameHistoryRepository, ContactRepository, MessageRepository, RawPacketRepository, - RepeaterAdvertPathRepository, ) from app.websocket import broadcast_error, broadcast_event @@ -150,6 +151,13 @@ async def create_message_from_decrypted( # Normalize channel key to uppercase for consistency channel_key_normalized = channel_key.upper() + # Resolve sender_key: look up contact by exact name match + resolved_sender_key: str | None = None + if sender: + candidates = await ContactRepository.get_by_name(sender) + if len(candidates) == 1: + resolved_sender_key = candidates[0].public_key + # Try to create message - INSERT OR IGNORE handles duplicates atomically msg_id = await MessageRepository.create( msg_type="CHAN", @@ -158,6 +166,8 @@ async def create_message_from_decrypted( sender_timestamp=timestamp, received_at=received, path=path, + sender_name=sender, + sender_key=resolved_sender_key, ) if msg_id is None: @@ -270,6 +280,7 @@ async def create_dm_message_from_decrypted( received_at=received, path=path, outgoing=outgoing, + sender_key=conversation_key if not outgoing else None, ) if msg_id is None: @@ -689,13 +700,20 @@ async def _process_advertisement( advert.device_role if advert.device_role > 0 else (existing.type if existing else 0) ) - # Keep recent unique advert paths for repeaters to improve frontend identity hints. - if contact_type == CONTACT_TYPE_REPEATER: - await RepeaterAdvertPathRepository.record_observation( - repeater_key=advert.public_key.lower(), - path_hex=new_path_hex, + # Keep recent unique advert paths for all contacts. + await ContactAdvertPathRepository.record_observation( + public_key=advert.public_key.lower(), + path_hex=new_path_hex, + timestamp=timestamp, + max_paths=10, + ) + + # Record name history + if advert.name: + await ContactNameHistoryRepository.record_name( + public_key=advert.public_key.lower(), + name=advert.name, timestamp=timestamp, - max_paths_per_repeater=10, ) contact_data = { @@ -708,6 +726,7 @@ async def _process_advertisement( "last_seen": timestamp, "last_path": path_hex, "last_path_len": path_len, + "first_seen": timestamp, # COALESCE in upsert preserves existing value } await ContactRepository.upsert(contact_data) diff --git a/app/radio_sync.py b/app/radio_sync.py index 3ac2d89..292036a 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -251,9 +251,15 @@ async def sync_and_offload_all(mc: MeshCore) -> dict: # Ensure default channels exist await ensure_default_channels() + # Reload favorites and recent contacts back onto the radio immediately + # so favorited contacts don't stay in the on_radio=False limbo until the + # next advertisement arrives. + reload_result = await sync_recent_contacts_to_radio(force=True) + return { "contacts": contacts_result, "channels": channels_result, + "reloaded": reload_result, } diff --git a/app/repository.py b/app/repository.py index 834514d..e4511b4 100644 --- a/app/repository.py +++ b/app/repository.py @@ -12,11 +12,12 @@ from app.models import ( BotConfig, Channel, Contact, + ContactAdvertPath, + ContactAdvertPathSummary, + ContactNameHistory, Favorite, Message, MessagePath, - RepeaterAdvertPath, - RepeaterAdvertPathSummary, ) logger = logging.getLogger(__name__) @@ -42,8 +43,9 @@ class ContactRepository: await db.conn.execute( """ INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len, - last_advert, lat, lon, last_seen, on_radio, last_contacted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + last_advert, lat, lon, last_seen, on_radio, last_contacted, + first_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(public_key) DO UPDATE SET name = COALESCE(excluded.name, contacts.name), type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END, @@ -54,8 +56,9 @@ class ContactRepository: lat = COALESCE(excluded.lat, contacts.lat), lon = COALESCE(excluded.lon, contacts.lon), last_seen = excluded.last_seen, - on_radio = excluded.on_radio, - last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted) + on_radio = COALESCE(excluded.on_radio, contacts.on_radio), + last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted), + first_seen = COALESCE(contacts.first_seen, excluded.first_seen) """, ( contact.get("public_key", "").lower(), @@ -68,8 +71,9 @@ class ContactRepository: contact.get("lat"), contact.get("lon"), contact.get("last_seen", int(time.time())), - contact.get("on_radio", False), + contact.get("on_radio"), contact.get("last_contacted"), + contact.get("first_seen"), ), ) await db.conn.commit() @@ -91,6 +95,7 @@ class ContactRepository: on_radio=bool(row["on_radio"]), last_contacted=row["last_contacted"], last_read_at=row["last_read_at"], + first_seen=row["first_seen"], ) @staticmethod @@ -148,6 +153,42 @@ class ContactRepository: ) return None + @staticmethod + async def get_by_name(name: str) -> list[Contact]: + """Get all contacts with the given exact name.""" + cursor = await db.conn.execute("SELECT * FROM contacts WHERE name = ?", (name,)) + rows = await cursor.fetchall() + return [ContactRepository._row_to_contact(row) for row in rows] + + @staticmethod + async def resolve_prefixes(prefixes: list[str]) -> dict[str, Contact]: + """Resolve multiple key prefixes to contacts in a single query. + + Returns a dict mapping each prefix to its Contact, only for prefixes + that resolve uniquely (exactly one match). Ambiguous or unmatched + prefixes are omitted. + """ + if not prefixes: + return {} + normalized = [p.lower() for p in prefixes] + conditions = " OR ".join(["public_key LIKE ?"] * len(normalized)) + params = [f"{p}%" for p in normalized] + cursor = await db.conn.execute(f"SELECT * FROM contacts WHERE {conditions}", params) + rows = await cursor.fetchall() + # Group by which prefix each row matches + prefix_to_rows: dict[str, list] = {p: [] for p in normalized} + for row in rows: + pk = row["public_key"] + for p in normalized: + if pk.startswith(p): + prefix_to_rows[p].append(row) + # Only include uniquely-resolved prefixes + result: dict[str, Contact] = {} + for p in normalized: + if len(prefix_to_rows[p]) == 1: + result[p] = ContactRepository._row_to_contact(prefix_to_rows[p][0]) + return result + @staticmethod async def get_all(limit: int = 100, offset: int = 0) -> list[Contact]: cursor = await db.conn.execute( @@ -194,10 +235,14 @@ class ContactRepository: @staticmethod async def delete(public_key: str) -> None: + normalized = public_key.lower() await db.conn.execute( - "DELETE FROM contacts WHERE public_key = ?", - (public_key.lower(),), + "DELETE FROM contact_name_history WHERE public_key = ?", (normalized,) ) + await db.conn.execute( + "DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,) + ) + await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,)) await db.conn.commit() @staticmethod @@ -241,14 +286,14 @@ class ContactRepository: return [ContactRepository._row_to_contact(row) for row in rows] -class RepeaterAdvertPathRepository: - """Repository for recent unique repeater advertisement paths.""" +class ContactAdvertPathRepository: + """Repository for recent unique advertisement paths per contact.""" @staticmethod - def _row_to_path(row) -> RepeaterAdvertPath: + def _row_to_path(row) -> ContactAdvertPath: path = row["path_hex"] or "" next_hop = path[:2].lower() if len(path) >= 2 else None - return RepeaterAdvertPath( + return ContactAdvertPath( path=path, path_len=row["path_len"], next_hop=next_hop, @@ -259,92 +304,129 @@ class RepeaterAdvertPathRepository: @staticmethod async def record_observation( - repeater_key: str, path_hex: str, timestamp: int, max_paths_per_repeater: int = 10 + public_key: str, + path_hex: str, + timestamp: int, + max_paths: int = 10, ) -> None: """ - Upsert a unique advert path observation for a repeater and prune to N most recent. + Upsert a unique advert path observation for a contact and prune to N most recent. """ - if max_paths_per_repeater < 1: - max_paths_per_repeater = 1 + if max_paths < 1: + max_paths = 1 - normalized_key = repeater_key.lower() + normalized_key = public_key.lower() normalized_path = path_hex.lower() path_len = len(normalized_path) // 2 await db.conn.execute( """ - INSERT INTO repeater_advert_paths - (repeater_key, path_hex, path_len, first_seen, last_seen, heard_count) + INSERT INTO contact_advert_paths + (public_key, path_hex, path_len, first_seen, last_seen, heard_count) VALUES (?, ?, ?, ?, ?, 1) - ON CONFLICT(repeater_key, path_hex) DO UPDATE SET - last_seen = MAX(repeater_advert_paths.last_seen, excluded.last_seen), + ON CONFLICT(public_key, path_hex) DO UPDATE SET + last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen), path_len = excluded.path_len, - heard_count = repeater_advert_paths.heard_count + 1 + heard_count = contact_advert_paths.heard_count + 1 """, (normalized_key, normalized_path, path_len, timestamp, timestamp), ) - # Keep only the N most recent unique paths per repeater. + # Keep only the N most recent unique paths per contact. await db.conn.execute( """ - DELETE FROM repeater_advert_paths - WHERE repeater_key = ? + DELETE FROM contact_advert_paths + WHERE public_key = ? AND path_hex NOT IN ( SELECT path_hex - FROM repeater_advert_paths - WHERE repeater_key = ? + FROM contact_advert_paths + WHERE public_key = ? ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC LIMIT ? ) """, - (normalized_key, normalized_key, max_paths_per_repeater), + (normalized_key, normalized_key, max_paths), ) await db.conn.commit() @staticmethod - async def get_recent_for_repeater( - repeater_key: str, limit: int = 10 - ) -> list[RepeaterAdvertPath]: + async def get_recent_for_contact(public_key: str, limit: int = 10) -> list[ContactAdvertPath]: cursor = await db.conn.execute( """ SELECT path_hex, path_len, first_seen, last_seen, heard_count - FROM repeater_advert_paths - WHERE repeater_key = ? + FROM contact_advert_paths + WHERE public_key = ? ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC LIMIT ? """, - (repeater_key.lower(), limit), + (public_key.lower(), limit), ) rows = await cursor.fetchall() - return [RepeaterAdvertPathRepository._row_to_path(row) for row in rows] + return [ContactAdvertPathRepository._row_to_path(row) for row in rows] @staticmethod - async def get_recent_for_all_repeaters( - limit_per_repeater: int = 10, - ) -> list[RepeaterAdvertPathSummary]: + async def get_recent_for_all_contacts( + limit_per_contact: int = 10, + ) -> list[ContactAdvertPathSummary]: cursor = await db.conn.execute( """ - SELECT repeater_key, path_hex, path_len, first_seen, last_seen, heard_count - FROM repeater_advert_paths - ORDER BY repeater_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC + SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count + FROM contact_advert_paths + ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC """ ) rows = await cursor.fetchall() - grouped: dict[str, list[RepeaterAdvertPath]] = {} + grouped: dict[str, list[ContactAdvertPath]] = {} for row in rows: - repeater_key = row["repeater_key"] - paths = grouped.get(repeater_key) + key = row["public_key"] + paths = grouped.get(key) if paths is None: paths = [] - grouped[repeater_key] = paths - if len(paths) >= limit_per_repeater: + grouped[key] = paths + if len(paths) >= limit_per_contact: continue - paths.append(RepeaterAdvertPathRepository._row_to_path(row)) + paths.append(ContactAdvertPathRepository._row_to_path(row)) return [ - RepeaterAdvertPathSummary(repeater_key=repeater_key, paths=paths) - for repeater_key, paths in grouped.items() + ContactAdvertPathSummary(public_key=key, paths=paths) for key, paths in grouped.items() + ] + + +class ContactNameHistoryRepository: + """Repository for contact name change history.""" + + @staticmethod + async def record_name(public_key: str, name: str, timestamp: int) -> None: + """Record a name observation. Upserts: updates last_seen if name already known.""" + await db.conn.execute( + """ + INSERT INTO contact_name_history (public_key, name, first_seen, last_seen) + VALUES (?, ?, ?, ?) + ON CONFLICT(public_key, name) DO UPDATE SET + last_seen = MAX(contact_name_history.last_seen, excluded.last_seen) + """, + (public_key.lower(), name, timestamp, timestamp), + ) + await db.conn.commit() + + @staticmethod + async def get_history(public_key: str) -> list[ContactNameHistory]: + cursor = await db.conn.execute( + """ + SELECT name, first_seen, last_seen + FROM contact_name_history + WHERE public_key = ? + ORDER BY last_seen DESC + """, + (public_key.lower(),), + ) + rows = await cursor.fetchall() + return [ + ContactNameHistory( + name=row["name"], first_seen=row["first_seen"], last_seen=row["last_seen"] + ) + for row in rows ] @@ -453,6 +535,8 @@ class MessageRepository: txt_type: int = 0, signature: str | None = None, outgoing: bool = False, + sender_name: str | None = None, + sender_key: str | None = None, ) -> int | None: """Create a message, returning the ID or None if duplicate. @@ -470,8 +554,9 @@ class MessageRepository: cursor = await db.conn.execute( """ INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp, - received_at, paths, txt_type, signature, outgoing) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + received_at, paths, txt_type, signature, outgoing, + sender_name, sender_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( msg_type, @@ -483,6 +568,8 @@ class MessageRepository: txt_type, signature, outgoing, + sender_name, + sender_key, ), ) await db.conn.commit() @@ -787,6 +874,48 @@ class MessageRepository: "last_message_times": last_message_times, } + @staticmethod + async def count_dm_messages(contact_key: str) -> int: + """Count total DM messages for a contact.""" + cursor = await db.conn.execute( + "SELECT COUNT(*) as cnt FROM messages WHERE type = 'PRIV' AND conversation_key = ?", + (contact_key.lower(),), + ) + row = await cursor.fetchone() + return row["cnt"] if row else 0 + + @staticmethod + async def count_channel_messages_by_sender(sender_key: str) -> int: + """Count channel messages sent by a specific contact.""" + cursor = await db.conn.execute( + "SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_key = ?", + (sender_key.lower(),), + ) + row = await cursor.fetchone() + return row["cnt"] if row else 0 + + @staticmethod + async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]: + """Get channels where a contact has sent the most messages. + + Returns list of (channel_key, channel_name, message_count) tuples. + """ + cursor = await db.conn.execute( + """ + SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) as channel_name, + COUNT(*) as cnt + FROM messages m + LEFT JOIN channels c ON m.conversation_key = c.key + WHERE m.type = 'CHAN' AND m.sender_key = ? + GROUP BY m.conversation_key + ORDER BY cnt DESC + LIMIT ? + """, + (sender_key.lower(), limit), + ) + rows = await cursor.fetchall() + return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows] + class RawPacketRepository: @staticmethod diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 8e4317b..3692bc6 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -14,10 +14,13 @@ from app.models import ( CommandRequest, CommandResponse, Contact, + ContactActiveRoom, + ContactAdvertPath, + ContactAdvertPathSummary, + ContactDetail, CreateContactRequest, + NearestRepeater, NeighborInfo, - RepeaterAdvertPath, - RepeaterAdvertPathSummary, TelemetryRequest, TelemetryResponse, TraceResponse, @@ -26,9 +29,10 @@ from app.packet_processor import start_historical_dm_decryption from app.radio import radio_manager from app.repository import ( AmbiguousPublicKeyPrefixError, + ContactAdvertPathRepository, + ContactNameHistoryRepository, ContactRepository, MessageRepository, - RepeaterAdvertPathRepository, ) from app.websocket import broadcast_error @@ -196,13 +200,17 @@ async def list_contacts( return await ContactRepository.get_all(limit=limit, offset=offset) -@router.get("/repeaters/advert-paths", response_model=list[RepeaterAdvertPathSummary]) +@router.get("/repeaters/advert-paths", response_model=list[ContactAdvertPathSummary]) async def list_repeater_advert_paths( limit_per_repeater: int = Query(default=10, ge=1, le=50), -) -> list[RepeaterAdvertPathSummary]: - """List recent unique advert paths for all repeaters.""" - return await RepeaterAdvertPathRepository.get_recent_for_all_repeaters( - limit_per_repeater=limit_per_repeater +) -> list[ContactAdvertPathSummary]: + """List recent unique advert paths for all repeaters. + + Note: This endpoint now returns paths for all contacts (table was renamed). + The route is kept for backward compatibility. + """ + return await ContactAdvertPathRepository.get_recent_for_all_contacts( + limit_per_contact=limit_per_repeater ) @@ -283,25 +291,98 @@ async def create_contact( return Contact(**contact_data) +@router.get("/{public_key}/detail", response_model=ContactDetail) +async def get_contact_detail(public_key: str) -> ContactDetail: + """Get comprehensive contact profile data. + + Returns contact info, name history, message counts, most active rooms, + advertisement paths, advert frequency, and nearest repeaters. + """ + contact = await _resolve_contact_or_404(public_key) + + name_history = await ContactNameHistoryRepository.get_history(contact.public_key) + dm_count = await MessageRepository.count_dm_messages(contact.public_key) + chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key) + active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key) + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key) + + most_active_rooms = [ + ContactActiveRoom(channel_key=key, channel_name=name, message_count=count) + for key, name, count in active_rooms_raw + ] + + # Compute advert observation rate (observations/hour) from path data. + # Note: a single advertisement can arrive via multiple paths, so this counts + # RF observations, not unique advertisement broadcasts. + advert_frequency: float | None = None + if advert_paths: + total_observations = sum(p.heard_count for p in advert_paths) + earliest = min(p.first_seen for p in advert_paths) + latest = max(p.last_seen for p in advert_paths) + span_hours = (latest - earliest) / 3600.0 + if span_hours > 0: + advert_frequency = round(total_observations / span_hours, 2) + + # Compute nearest repeaters from first-hop prefixes in advert paths + first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen} + for p in advert_paths: + if p.path and len(p.path) >= 2: + prefix = p.path[:2].lower() + if prefix not in first_hop_stats: + first_hop_stats[prefix] = { + "heard_count": 0, + "path_len": p.path_len, + "last_seen": p.last_seen, + } + first_hop_stats[prefix]["heard_count"] += p.heard_count + first_hop_stats[prefix]["last_seen"] = max( + first_hop_stats[prefix]["last_seen"], p.last_seen + ) + + # Resolve all first-hop prefixes to contacts in a single query + resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys())) + + nearest_repeaters: list[NearestRepeater] = [] + for prefix, stats in first_hop_stats.items(): + resolved = resolved_contacts.get(prefix) + nearest_repeaters.append( + NearestRepeater( + public_key=resolved.public_key if resolved else prefix, + name=resolved.name if resolved else None, + path_len=stats["path_len"], + last_seen=stats["last_seen"], + heard_count=stats["heard_count"], + ) + ) + + nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True) + + return ContactDetail( + contact=contact, + name_history=name_history, + dm_message_count=dm_count, + channel_message_count=chan_count, + most_active_rooms=most_active_rooms, + advert_paths=advert_paths, + advert_frequency=advert_frequency, + nearest_repeaters=nearest_repeaters, + ) + + @router.get("/{public_key}", response_model=Contact) async def get_contact(public_key: str) -> Contact: """Get a specific contact by public key or prefix.""" return await _resolve_contact_or_404(public_key) -@router.get("/{public_key}/advert-paths", response_model=list[RepeaterAdvertPath]) +@router.get("/{public_key}/advert-paths", response_model=list[ContactAdvertPath]) async def get_contact_advert_paths( public_key: str, limit: int = Query(default=10, ge=1, le=50), -) -> list[RepeaterAdvertPath]: - """List recent unique advert paths for a single repeater contact.""" +) -> list[ContactAdvertPath]: + """List recent unique advert paths for a contact.""" contact = await _resolve_contact_or_404(public_key) - if contact.type != CONTACT_TYPE_REPEATER: - raise HTTPException( - status_code=400, - detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})", - ) - return await RepeaterAdvertPathRepository.get_recent_for_repeater(contact.public_key, limit) + return await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key, limit) @router.post("/sync") diff --git a/app/routers/settings.py b/app/routers/settings.py index 086cdd2..89583b4 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import Literal @@ -137,10 +138,19 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings: if is_favorited: logger.info("Removing favorite: %s %s", request.type, request.id[:12]) - return await AppSettingsRepository.remove_favorite(request.type, request.id) + result = await AppSettingsRepository.remove_favorite(request.type, request.id) else: logger.info("Adding favorite: %s %s", request.type, request.id[:12]) - return await AppSettingsRepository.add_favorite(request.type, request.id) + result = await AppSettingsRepository.add_favorite(request.type, request.id) + + # When a contact favorite changes, sync the radio so the contact is + # loaded/unloaded immediately rather than waiting for the next advert. + if request.type == "contact": + from app.radio_sync import sync_recent_contacts_to_radio + + asyncio.create_task(sync_recent_contacts_to_radio(force=True)) + + return result @router.post("/migrate", response_model=MigratePreferencesResponse) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index f5ba827..adaee42 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -67,6 +67,7 @@ frontend/src/ │ ├── CrackerPanel.tsx │ ├── BotCodeEditor.tsx │ ├── ContactAvatar.tsx +│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths) │ └── ui/ # shadcn/ui primitives ├── types/ │ └── d3-force-3d.d.ts # Type declarations for d3-force-3d diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 26123c2..99ec2ee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,6 +34,7 @@ import { type SettingsSection, } from './components/settingsConstants'; import { RawPacketList } from './components/RawPacketList'; +import { ContactInfoPane } from './components/ContactInfoPane'; // Lazy-load heavy components to reduce initial bundle const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView }))); @@ -68,6 +69,7 @@ export function App() { const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); const [localLabel, setLocalLabel] = useState(getLocalLabel); + const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); // Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state) const crackerMounted = useRef(false); @@ -410,6 +412,25 @@ export function App() { setShowCracker((prev) => !prev); }, []); + const handleOpenContactInfo = useCallback((publicKey: string) => { + setInfoPaneContactKey(publicKey); + }, []); + + const handleCloseContactInfo = useCallback(() => { + setInfoPaneContactKey(null); + }, []); + + const handleNavigateToChannel = useCallback( + (channelKey: string) => { + const channel = channels.find((c) => c.key === channelKey); + if (channel) { + handleSelectConversation({ type: 'channel', id: channel.key, name: channel.name }); + setInfoPaneContactKey(null); + } + }, + [channels, handleSelectConversation] + ); + // Sidebar content (shared between desktop and mobile) const sidebarContent = ( + + ); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 95ba171..203a1aa 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -4,6 +4,9 @@ import type { Channel, CommandResponse, Contact, + ContactAdvertPath, + ContactAdvertPathSummary, + ContactDetail, Favorite, HealthStatus, MaintenanceResult, @@ -12,8 +15,6 @@ import type { MigratePreferencesResponse, RadioConfig, RadioConfigUpdate, - RepeaterAdvertPath, - RepeaterAdvertPathSummary, StatisticsResponse, TelemetryResponse, TraceResponse, @@ -97,11 +98,13 @@ export const api = { getContacts: (limit = 100, offset = 0) => fetchJson(`/contacts?limit=${limit}&offset=${offset}`), getRepeaterAdvertPaths: (limitPerRepeater = 10) => - fetchJson( + fetchJson( `/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}` ), getContactAdvertPaths: (publicKey: string, limit = 10) => - fetchJson(`/contacts/${publicKey}/advert-paths?limit=${limit}`), + fetchJson(`/contacts/${publicKey}/advert-paths?limit=${limit}`), + getContactDetail: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/detail`), deleteContact: (publicKey: string) => fetchJson<{ status: string }>(`/contacts/${publicKey}`, { method: 'DELETE', diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 70bcde5..41e29f1 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -4,6 +4,7 @@ import { formatTime } from '../utils/messageParser'; import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; import { getMapFocusHash } from '../utils/urlHash'; import { isFavorite } from '../utils/favorites'; +import { ContactAvatar } from './ContactAvatar'; import type { Contact, Conversation, Favorite, RadioConfig } from '../types'; interface ChatHeaderProps { @@ -15,6 +16,7 @@ interface ChatHeaderProps { onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onDeleteChannel: (key: string) => void; onDeleteContact: (publicKey: string) => void; + onOpenContactInfo?: (publicKey: string) => void; } export function ChatHeader({ @@ -26,11 +28,34 @@ export function ChatHeader({ onToggleFavorite, onDeleteChannel, onDeleteContact, + onOpenContactInfo, }: ChatHeaderProps) { return (
- - + + {conversation.type === 'contact' && onOpenContactInfo && ( + onOpenContactInfo(conversation.id)} + title="View contact info" + > + c.public_key === conversation.id)?.type} + clickable + /> + + )} + onOpenContactInfo(conversation.id) + : undefined + } + > {conversation.type === 'channel' && !conversation.name.startsWith('#') && conversation.name !== 'Public' diff --git a/frontend/src/components/ContactAvatar.tsx b/frontend/src/components/ContactAvatar.tsx index ee34264..6e70c8c 100644 --- a/frontend/src/components/ContactAvatar.tsx +++ b/frontend/src/components/ContactAvatar.tsx @@ -5,14 +5,21 @@ interface ContactAvatarProps { publicKey: string; size?: number; contactType?: number; + clickable?: boolean; } -export function ContactAvatar({ name, publicKey, size = 28, contactType }: ContactAvatarProps) { +export function ContactAvatar({ + name, + publicKey, + size = 28, + contactType, + clickable, +}: ContactAvatarProps) { const avatar = getContactAvatar(name, publicKey, contactType); return (
= { + 0: 'Unknown', + 1: 'Client', + 2: 'Repeater', + 3: 'Room', + 4: 'Sensor', +}; + +interface ContactInfoPaneProps { + contactKey: string | null; + onClose: () => void; + contacts: Contact[]; + config: RadioConfig | null; + favorites: Favorite[]; + onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; + onNavigateToChannel?: (channelKey: string) => void; +} + +export function ContactInfoPane({ + contactKey, + onClose, + contacts, + config, + favorites, + onToggleFavorite, + onNavigateToChannel, +}: ContactInfoPaneProps) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + + // Get live contact data from contacts array (real-time via WS) + const liveContact = contactKey + ? (contacts.find((c) => c.public_key === contactKey) ?? null) + : null; + + useEffect(() => { + if (!contactKey) { + setDetail(null); + return; + } + + setLoading(true); + api + .getContactDetail(contactKey) + .then(setDetail) + .catch((err) => { + console.error('Failed to fetch contact detail:', err); + toast.error('Failed to load contact info'); + }) + .finally(() => setLoading(false)); + }, [contactKey]); + + // Use live contact data where available, fall back to detail snapshot + const contact = liveContact ?? detail?.contact ?? null; + + const distFromUs = + contact && + config && + isValidLocation(config.lat, config.lon) && + isValidLocation(contact.lat, contact.lon) + ? calculateDistance(config.lat, config.lon, contact.lat, contact.lon) + : null; + + return ( + !open && onClose()}> + + + Contact Info + + + {loading && !detail ? ( +
+ Loading... +
+ ) : contact ? ( +
+ {/* Header */} +
+
+ +
+

+ {contact.name || contact.public_key.slice(0, 12)} +

+ { + navigator.clipboard.writeText(contact.public_key); + toast.success('Public key copied!'); + }} + title="Click to copy" + > + {contact.public_key} + +
+ + {CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'} + + {contact.on_radio && ( + + On Radio + + )} +
+
+
+
+ + {/* Info grid */} +
+
+ {contact.last_seen && ( + + )} + {contact.first_seen && ( + + )} + {contact.last_contacted && ( + + )} + {distFromUs !== null && ( + + )} + {contact.last_path_len >= 0 && ( + 1 ? 's' : ''}` + } + /> + )} + {contact.last_path_len === -1 && } +
+
+ + {/* GPS */} + {isValidLocation(contact.lat, contact.lon) && ( +
+ Location + { + const url = + window.location.origin + + window.location.pathname + + getMapFocusHash(contact.public_key); + window.open(url, '_blank'); + }} + title="View on map" + > + {contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)} + +
+ )} + + {/* Favorite toggle */} +
+ +
+ + {/* AKA (Name History) - only show if more than one name */} + {detail && detail.name_history.length > 1 && ( +
+ Also Known As +
+ {detail.name_history.map((h) => ( +
+ {h.name} + + {formatTime(h.first_seen)} – {formatTime(h.last_seen)} + +
+ ))} +
+
+ )} + + {/* Message Stats */} + {detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && ( +
+ Messages +
+ {detail.dm_message_count > 0 && ( + + )} + {detail.channel_message_count > 0 && ( + + )} +
+
+ )} + + {/* Most Active Rooms */} + {detail && detail.most_active_rooms.length > 0 && ( +
+ Most Active Rooms +
+ {detail.most_active_rooms.map((room) => ( +
+ onNavigateToChannel?.(room.channel_key)} + > + {room.channel_name.startsWith('#') || room.channel_name === 'Public' + ? room.channel_name + : `#${room.channel_name}`} + + + {room.message_count.toLocaleString()} msg + {room.message_count !== 1 ? 's' : ''} + +
+ ))} +
+
+ )} + + {/* Advert Info */} + {detail && detail.advert_frequency !== null && ( +
+ Advert Observations +

{detail.advert_frequency.toFixed(1)} observations/hour

+
+ )} + + {/* Nearest Repeaters */} + {detail && detail.nearest_repeaters.length > 0 && ( +
+ Nearest Repeaters +
+ {detail.nearest_repeaters.map((r) => ( +
+ {r.name || r.public_key.slice(0, 12)} + + {r.path_len === 0 + ? 'direct' + : `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '} + · {r.heard_count}x + +
+ ))} +
+
+ )} + + {/* Advert Paths */} + {detail && detail.advert_paths.length > 0 && ( +
+ Recent Advert Paths +
+ {detail.advert_paths.map((p) => ( +
+ {p.path || '(direct)'} + + {p.heard_count}x · {formatTime(p.last_seen)} + +
+ ))} +
+
+ )} +
+ ) : ( +
+ Contact not found +
+ )} +
+
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +

{value}

+
+ ); +} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 03794a8..9c1b9e8 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -26,6 +26,7 @@ interface MessageListProps { onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void; radioName?: string; config?: RadioConfig | null; + onOpenContactInfo?: (publicKey: string) => void; } // URL regex for linkifying plain text @@ -148,6 +149,7 @@ export function MessageList({ onResendChannelMessage, radioName, config, + onOpenContactInfo, }: MessageListProps) { const listRef = useRef(null); const prevMessagesLengthRef = useRef(0); @@ -466,7 +468,20 @@ export function MessageList({ {!msg.outgoing && (
{showAvatar && avatarKey && ( - + onOpenContactInfo(avatarKey) + : undefined + } + > + + )}
)} diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx index 946ed8e..6022231 100644 --- a/frontend/src/components/PacketVisualizer3D.tsx +++ b/frontend/src/components/PacketVisualizer3D.tsx @@ -22,7 +22,7 @@ import { type Contact, type RawPacket, type RadioConfig, - type RepeaterAdvertPathSummary, + type ContactAdvertPathSummary, } from '../types'; import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; import { Checkbox } from './ui/checkbox'; @@ -118,7 +118,7 @@ interface UseVisualizerData3DOptions { packets: RawPacket[]; contacts: Contact[]; config: RadioConfig | null; - repeaterAdvertPaths: RepeaterAdvertPathSummary[]; + repeaterAdvertPaths: ContactAdvertPathSummary[]; showAmbiguousPaths: boolean; showAmbiguousNodes: boolean; useAdvertPathHints: boolean; @@ -195,9 +195,9 @@ function useVisualizerData3D({ }, [contacts]); const advertPathIndex = useMemo(() => { - const byRepeater = new Map(); + const byRepeater = new Map(); for (const summary of repeaterAdvertPaths) { - const key = summary.repeater_key.slice(0, 12).toLowerCase(); + const key = summary.public_key.slice(0, 12).toLowerCase(); byRepeater.set(key, summary.paths); } return { byRepeater }; @@ -1018,7 +1018,7 @@ export function PacketVisualizer3D({ const [showControls, setShowControls] = useState(true); const [autoOrbit, setAutoOrbit] = useState(false); const [pruneStaleNodes, setPruneStaleNodes] = useState(false); - const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState([]); + const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState([]); useEffect(() => { let cancelled = false; diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 31863c1..adf9ebb 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -272,6 +272,7 @@ describe('App startup hash resolution', () => { on_radio: false, last_contacted: null, last_read_at: null, + first_seen: null, }; window.location.hash = ''; diff --git a/frontend/src/test/integration.test.ts b/frontend/src/test/integration.test.ts index a958ec7..842f69e 100644 --- a/frontend/src/test/integration.test.ts +++ b/frontend/src/test/integration.test.ts @@ -282,6 +282,7 @@ function makeContact(overrides: Partial = {}): Contact { on_radio: true, last_contacted: null, last_read_at: null, + first_seen: null, ...overrides, }; } diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts index 07fb834..1678389 100644 --- a/frontend/src/test/pathUtils.test.ts +++ b/frontend/src/test/pathUtils.test.ts @@ -26,6 +26,7 @@ function createContact(overrides: Partial = {}): Contact { on_radio: false, last_contacted: null, last_read_at: null, + first_seen: null, ...overrides, }; } diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index 5402c04..76a4b3e 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -30,6 +30,7 @@ function makeContact(public_key: string, name: string, type = 1): Contact { on_radio: false, last_contacted: null, last_read_at: null, + first_seen: null, }; } diff --git a/frontend/src/test/urlHash.test.ts b/frontend/src/test/urlHash.test.ts index 4255c0d..8d15043 100644 --- a/frontend/src/test/urlHash.test.ts +++ b/frontend/src/test/urlHash.test.ts @@ -203,6 +203,7 @@ describe('resolveContactFromHashToken', () => { on_radio: false, last_contacted: null, last_read_at: null, + first_seen: null, }, { public_key: 'def456abc1237890def456abc1237890def456abc1237890def456abc1237890', @@ -218,6 +219,7 @@ describe('resolveContactFromHashToken', () => { on_radio: false, last_contacted: null, last_read_at: null, + first_seen: null, }, { public_key: 'eeeeee111111222222333333444444555555666666777777888888999999aaaa', @@ -233,6 +235,7 @@ describe('resolveContactFromHashToken', () => { on_radio: false, last_contacted: null, last_read_at: null, + first_seen: null, }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f151ffe..740ec1e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -50,9 +50,10 @@ export interface Contact { on_radio: boolean; last_contacted: number | null; last_read_at: number | null; + first_seen: number | null; } -export interface RepeaterAdvertPath { +export interface ContactAdvertPath { path: string; path_len: number; next_hop: string | null; @@ -61,9 +62,40 @@ export interface RepeaterAdvertPath { heard_count: number; } -export interface RepeaterAdvertPathSummary { - repeater_key: string; - paths: RepeaterAdvertPath[]; +export interface ContactAdvertPathSummary { + public_key: string; + paths: ContactAdvertPath[]; +} + +export interface ContactNameHistory { + name: string; + first_seen: number; + last_seen: number; +} + +export interface ContactActiveRoom { + channel_key: string; + channel_name: string; + message_count: number; +} + +export interface NearestRepeater { + public_key: string; + name: string | null; + path_len: number; + last_seen: number; + heard_count: number; +} + +export interface ContactDetail { + contact: Contact; + name_history: ContactNameHistory[]; + dm_message_count: number; + channel_message_count: number; + most_active_rooms: ContactActiveRoom[]; + advert_paths: ContactAdvertPath[]; + advert_frequency: number | null; + nearest_repeaters: NearestRepeater[]; } export interface Channel { diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 63e8672..6231c16 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -15,7 +15,7 @@ from meshcore import EventType from app.database import Database from app.radio import radio_manager -from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository +from app.repository import ContactAdvertPathRepository, ContactRepository, MessageRepository # Sample 64-char hex public keys for testing KEY_A = "aa" * 32 # aaaa...aa @@ -215,15 +215,15 @@ class TestAdvertPaths: async def test_list_repeater_advert_paths(self, test_db, client): repeater_key = KEY_A await _insert_contact(repeater_key, "R1", type=2) - await RepeaterAdvertPathRepository.record_observation(repeater_key, "1122", 1000) - await RepeaterAdvertPathRepository.record_observation(repeater_key, "3344", 1010) + await ContactAdvertPathRepository.record_observation(repeater_key, "1122", 1000) + await ContactAdvertPathRepository.record_observation(repeater_key, "3344", 1010) response = await client.get("/api/contacts/repeaters/advert-paths?limit_per_repeater=1") assert response.status_code == 200 data = response.json() assert len(data) == 1 - assert data[0]["repeater_key"] == repeater_key + assert data[0]["public_key"] == repeater_key assert len(data[0]["paths"]) == 1 assert data[0]["paths"][0]["path"] == "3344" assert data[0]["paths"][0]["next_hop"] == "33" @@ -232,7 +232,7 @@ class TestAdvertPaths: async def test_get_contact_advert_paths_for_repeater(self, test_db, client): repeater_key = KEY_A await _insert_contact(repeater_key, "R1", type=2) - await RepeaterAdvertPathRepository.record_observation(repeater_key, "", 1000) + await ContactAdvertPathRepository.record_observation(repeater_key, "", 1000) response = await client.get(f"/api/contacts/{repeater_key}/advert-paths") @@ -243,13 +243,164 @@ class TestAdvertPaths: assert data[0]["next_hop"] is None @pytest.mark.asyncio - async def test_get_contact_advert_paths_rejects_non_repeater(self, test_db, client): + async def test_get_contact_advert_paths_works_for_non_repeater(self, test_db, client): await _insert_contact(KEY_A, "Alice", type=1) response = await client.get(f"/api/contacts/{KEY_A}/advert-paths") - assert response.status_code == 400 - assert "not a repeater" in response.json()["detail"].lower() + assert response.status_code == 200 + assert response.json() == [] + + +class TestContactDetail: + """Test GET /api/contacts/{public_key}/detail.""" + + @pytest.mark.asyncio + async def test_detail_returns_full_profile(self, test_db, client): + """Happy path: contact with DMs, channel messages, name history, advert paths.""" + await _insert_contact(KEY_A, "Alice", type=1) + + # Add some DMs + await MessageRepository.create( + msg_type="PRIV", + text="hi", + conversation_key=KEY_A, + sender_timestamp=1000, + received_at=1000, + sender_key=KEY_A, + ) + await MessageRepository.create( + msg_type="PRIV", + text="hello", + conversation_key=KEY_A, + sender_timestamp=1001, + received_at=1001, + outgoing=True, + ) + + # Add a channel message attributed to this contact + from app.repository import ContactNameHistoryRepository + + await MessageRepository.create( + msg_type="CHAN", + text="Alice: yo", + conversation_key="CHAN_KEY_0" * 2, + sender_timestamp=1002, + received_at=1002, + sender_name="Alice", + sender_key=KEY_A, + ) + + # Record name history + await ContactNameHistoryRepository.record_name(KEY_A, "Alice", 1000) + await ContactNameHistoryRepository.record_name(KEY_A, "AliceOld", 500) + + # Record advert paths + await ContactAdvertPathRepository.record_observation(KEY_A, "1122", 1000) + await ContactAdvertPathRepository.record_observation(KEY_A, "", 900) + + response = await client.get(f"/api/contacts/{KEY_A}/detail") + + assert response.status_code == 200 + data = response.json() + assert data["contact"]["public_key"] == KEY_A + assert data["dm_message_count"] == 2 + assert data["channel_message_count"] == 1 + assert len(data["name_history"]) == 2 + assert data["name_history"][0]["name"] == "Alice" # most recent first + assert len(data["advert_paths"]) == 2 + assert len(data["most_active_rooms"]) == 1 + + @pytest.mark.asyncio + async def test_detail_contact_not_found(self, test_db, client): + response = await client.get(f"/api/contacts/{KEY_A}/detail") + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_detail_with_no_activity(self, test_db, client): + """Contact with no messages or paths returns zero counts and empty lists.""" + await _insert_contact(KEY_A, "Alice") + + response = await client.get(f"/api/contacts/{KEY_A}/detail") + + assert response.status_code == 200 + data = response.json() + assert data["dm_message_count"] == 0 + assert data["channel_message_count"] == 0 + assert data["most_active_rooms"] == [] + assert data["advert_paths"] == [] + assert data["advert_frequency"] is None + assert data["nearest_repeaters"] == [] + + @pytest.mark.asyncio + async def test_detail_nearest_repeaters_resolved(self, test_db, client): + """Nearest repeaters are resolved from first-hop prefixes in advert paths.""" + await _insert_contact(KEY_A, "Alice", type=1) + # Create a repeater whose key starts with "bb" + await _insert_contact(KEY_B, "Relay1", type=2) + + # Record advert paths that go through KEY_B's prefix + await ContactAdvertPathRepository.record_observation(KEY_A, "bb1122", 1000) + await ContactAdvertPathRepository.record_observation(KEY_A, "bb3344", 1010) + + response = await client.get(f"/api/contacts/{KEY_A}/detail") + + assert response.status_code == 200 + data = response.json() + assert len(data["nearest_repeaters"]) == 1 + repeater = data["nearest_repeaters"][0] + assert repeater["public_key"] == KEY_B + assert repeater["name"] == "Relay1" + assert repeater["heard_count"] == 2 + + @pytest.mark.asyncio + async def test_detail_advert_frequency_computed(self, test_db, client): + """Advert frequency is computed from path observations over time span.""" + await _insert_contact(KEY_A, "Alice") + + # 10 observations over 1 hour (3600s) + for i in range(10): + path_hex = f"{i:02x}" * 2 # unique paths to avoid upsert + await ContactAdvertPathRepository.record_observation(KEY_A, path_hex, 1000 + i * 360) + + response = await client.get(f"/api/contacts/{KEY_A}/detail") + + assert response.status_code == 200 + data = response.json() + # 10 observations / (3240s / 3600) ≈ 11.11/hr + assert data["advert_frequency"] is not None + assert data["advert_frequency"] > 0 + + +class TestDeleteContactCascade: + """Test that contact delete cleans up related tables.""" + + @pytest.mark.asyncio + async def test_delete_removes_name_history_and_advert_paths(self, test_db, client): + await _insert_contact(KEY_A, "Alice") + + from app.repository import ContactNameHistoryRepository + + await ContactNameHistoryRepository.record_name(KEY_A, "Alice", 1000) + await ContactAdvertPathRepository.record_observation(KEY_A, "1122", 1000) + + # Verify data exists + assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 1 + assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 1 + + with patch("app.routers.contacts.radio_manager") as mock_rm: + mock_rm.is_connected = False + mock_rm.meshcore = None + mock_rm.radio_operation = _noop_radio_operation() + + response = await client.delete(f"/api/contacts/{KEY_A}") + + assert response.status_code == 200 + + # Verify related data cleaned up + assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 0 + assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 0 class TestMarkRead: diff --git a/tests/test_migrations.py b/tests/test_migrations.py index d609187..7b1cd9a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -100,8 +100,8 @@ class TestMigration001: # Run migrations applied = await run_migrations(conn) - assert applied == 22 # All migrations run - assert await get_version(conn) == 22 + assert applied == 27 # All migrations run + assert await get_version(conn) == 27 # Verify columns exist by inserting and selecting await conn.execute( @@ -183,9 +183,9 @@ class TestMigration001: applied1 = await run_migrations(conn) applied2 = await run_migrations(conn) - assert applied1 == 22 # All migrations run + assert applied1 == 27 # All migrations run assert applied2 == 0 # No migrations on second run - assert await get_version(conn) == 22 + assert await get_version(conn) == 27 finally: await conn.close() @@ -246,8 +246,8 @@ class TestMigration001: applied = await run_migrations(conn) # All migrations applied (version incremented) but no error - assert applied == 22 - assert await get_version(conn) == 22 + assert applied == 27 + assert await get_version(conn) == 27 finally: await conn.close() @@ -374,10 +374,10 @@ class TestMigration013: ) await conn.commit() - # Run migration 13 (plus 14-22 which also run) + # Run migration 13 (plus 14-27 which also run) applied = await run_migrations(conn) - assert applied == 10 - assert await get_version(conn) == 22 + assert applied == 15 + assert await get_version(conn) == 27 # Verify bots array was created with migrated data cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1") @@ -497,7 +497,7 @@ class TestMigration018: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 22 + assert await get_version(conn) == 27 # Verify autoindex is gone cursor = await conn.execute( @@ -571,8 +571,8 @@ class TestMigration018: await conn.commit() applied = await run_migrations(conn) - assert applied == 5 # Migrations 18+19+20+21+22 run (18+19 skip internally) - assert await get_version(conn) == 22 + assert applied == 10 # Migrations 18-27 run (18+19 skip internally) + assert await get_version(conn) == 27 finally: await conn.close() @@ -644,7 +644,7 @@ class TestMigration019: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 22 + assert await get_version(conn) == 27 # Verify autoindex is gone cursor = await conn.execute( @@ -710,8 +710,8 @@ class TestMigration020: assert (await cursor.fetchone())[0] == "delete" applied = await run_migrations(conn) - assert applied == 3 # Migrations 20+21+22 - assert await get_version(conn) == 22 + assert applied == 8 # Migrations 20-27 + assert await get_version(conn) == 27 # Verify WAL mode cursor = await conn.execute("PRAGMA journal_mode") @@ -741,7 +741,7 @@ class TestMigration020: await set_version(conn, 20) applied = await run_migrations(conn) - assert applied == 2 # Migrations 21+22 still run + assert applied == 7 # Migrations 21-27 still run # Still WAL + INCREMENTAL cursor = await conn.execute("PRAGMA journal_mode") diff --git a/tests/test_repository.py b/tests/test_repository.py index 7291689..3537190 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -5,7 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.database import Database -from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository +from app.repository import ( + ContactAdvertPathRepository, + ContactNameHistoryRepository, + ContactRepository, + MessageRepository, +) @pytest.fixture @@ -267,18 +272,18 @@ class TestMessageRepositoryGetByContent: assert result.paths is None -class TestRepeaterAdvertPathRepository: - """Test storing and retrieving recent unique repeater advert paths.""" +class TestContactAdvertPathRepository: + """Test storing and retrieving recent unique advert paths.""" @pytest.mark.asyncio async def test_record_observation_upserts_and_tracks_count(self, test_db): repeater_key = "aa" * 32 await ContactRepository.upsert({"public_key": repeater_key, "name": "R1", "type": 2}) - await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1000) - await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1010) + await ContactAdvertPathRepository.record_observation(repeater_key, "112233", 1000) + await ContactAdvertPathRepository.record_observation(repeater_key, "112233", 1010) - paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10) + paths = await ContactAdvertPathRepository.get_recent_for_contact(repeater_key, limit=10) assert len(paths) == 1 assert paths[0].path == "112233" assert paths[0].path_len == 3 @@ -292,17 +297,11 @@ class TestRepeaterAdvertPathRepository: repeater_key = "bb" * 32 await ContactRepository.upsert({"public_key": repeater_key, "name": "R2", "type": 2}) - await RepeaterAdvertPathRepository.record_observation( - repeater_key, "aa", 1000, max_paths_per_repeater=2 - ) - await RepeaterAdvertPathRepository.record_observation( - repeater_key, "bb", 1001, max_paths_per_repeater=2 - ) - await RepeaterAdvertPathRepository.record_observation( - repeater_key, "cc", 1002, max_paths_per_repeater=2 - ) + await ContactAdvertPathRepository.record_observation(repeater_key, "aa", 1000, max_paths=2) + await ContactAdvertPathRepository.record_observation(repeater_key, "bb", 1001, max_paths=2) + await ContactAdvertPathRepository.record_observation(repeater_key, "cc", 1002, max_paths=2) - paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10) + paths = await ContactAdvertPathRepository.get_recent_for_contact(repeater_key, limit=10) assert [p.path for p in paths] == ["cc", "bb"] @pytest.mark.asyncio @@ -312,14 +311,12 @@ class TestRepeaterAdvertPathRepository: await ContactRepository.upsert({"public_key": repeater_a, "name": "RA", "type": 2}) await ContactRepository.upsert({"public_key": repeater_b, "name": "RB", "type": 2}) - await RepeaterAdvertPathRepository.record_observation(repeater_a, "01", 1000) - await RepeaterAdvertPathRepository.record_observation(repeater_a, "02", 1001) - await RepeaterAdvertPathRepository.record_observation(repeater_b, "", 1002) + await ContactAdvertPathRepository.record_observation(repeater_a, "01", 1000) + await ContactAdvertPathRepository.record_observation(repeater_a, "02", 1001) + await ContactAdvertPathRepository.record_observation(repeater_b, "", 1002) - grouped = await RepeaterAdvertPathRepository.get_recent_for_all_repeaters( - limit_per_repeater=1 - ) - by_key = {item.repeater_key: item.paths for item in grouped} + grouped = await ContactAdvertPathRepository.get_recent_for_all_contacts(limit_per_contact=1) + by_key = {item.public_key: item.paths for item in grouped} assert repeater_a in by_key assert repeater_b in by_key @@ -329,6 +326,173 @@ class TestRepeaterAdvertPathRepository: assert by_key[repeater_b][0].next_hop is None +class TestContactNameHistoryRepository: + """Test contact name history tracking.""" + + @pytest.mark.asyncio + async def test_record_and_retrieve_name_history(self, test_db): + key = "aa" * 32 + await ContactRepository.upsert({"public_key": key, "name": "Alice", "type": 1}) + + await ContactNameHistoryRepository.record_name(key, "Alice", 1000) + await ContactNameHistoryRepository.record_name(key, "AliceV2", 2000) + + history = await ContactNameHistoryRepository.get_history(key) + assert len(history) == 2 + assert history[0].name == "AliceV2" # most recent first + assert history[1].name == "Alice" + + @pytest.mark.asyncio + async def test_record_name_upserts_last_seen(self, test_db): + key = "bb" * 32 + await ContactRepository.upsert({"public_key": key, "name": "Bob", "type": 1}) + + await ContactNameHistoryRepository.record_name(key, "Bob", 1000) + await ContactNameHistoryRepository.record_name(key, "Bob", 2000) + + history = await ContactNameHistoryRepository.get_history(key) + assert len(history) == 1 + assert history[0].first_seen == 1000 + assert history[0].last_seen == 2000 + + +class TestMessageRepositoryContactStats: + """Test per-contact message counting methods.""" + + @pytest.mark.asyncio + async def test_count_dm_messages(self, test_db): + key = "aa" * 32 + await ContactRepository.upsert({"public_key": key, "name": "Alice", "type": 1}) + + await MessageRepository.create( + msg_type="PRIV", + text="hi", + conversation_key=key, + sender_timestamp=1000, + received_at=1000, + sender_key=key, + ) + await MessageRepository.create( + msg_type="PRIV", + text="hello back", + conversation_key=key, + sender_timestamp=1001, + received_at=1001, + outgoing=True, + ) + # Different contact's DM should not be counted + other_key = "bb" * 32 + await MessageRepository.create( + msg_type="PRIV", + text="hey", + conversation_key=other_key, + sender_timestamp=1002, + received_at=1002, + sender_key=other_key, + ) + + count = await MessageRepository.count_dm_messages(key) + assert count == 2 + + @pytest.mark.asyncio + async def test_count_channel_messages_by_sender(self, test_db): + key = "aa" * 32 + chan_key = "CC" * 16 + + await MessageRepository.create( + msg_type="CHAN", + text="Alice: msg1", + conversation_key=chan_key, + sender_timestamp=1000, + received_at=1000, + sender_name="Alice", + sender_key=key, + ) + await MessageRepository.create( + msg_type="CHAN", + text="Alice: msg2", + conversation_key=chan_key, + sender_timestamp=1001, + received_at=1001, + sender_name="Alice", + sender_key=key, + ) + + count = await MessageRepository.count_channel_messages_by_sender(key) + assert count == 2 + + @pytest.mark.asyncio + async def test_get_most_active_rooms(self, test_db): + key = "aa" * 32 + chan_a = "AA" * 16 + chan_b = "BB" * 16 + + from app.repository import ChannelRepository + + await ChannelRepository.upsert(chan_a, "General") + await ChannelRepository.upsert(chan_b, "Random") + + # 3 messages in chan_a, 1 in chan_b + for i in range(3): + await MessageRepository.create( + msg_type="CHAN", + text=f"Alice: msg{i}", + conversation_key=chan_a, + sender_timestamp=1000 + i, + received_at=1000 + i, + sender_name="Alice", + sender_key=key, + ) + await MessageRepository.create( + msg_type="CHAN", + text="Alice: hi", + conversation_key=chan_b, + sender_timestamp=2000, + received_at=2000, + sender_name="Alice", + sender_key=key, + ) + + rooms = await MessageRepository.get_most_active_rooms(key, limit=5) + assert len(rooms) == 2 + assert rooms[0][0] == chan_a # most active first + assert rooms[0][1] == "General" + assert rooms[0][2] == 3 + assert rooms[1][2] == 1 + + +class TestContactRepositoryResolvePrefixes: + """Test batch prefix resolution.""" + + @pytest.mark.asyncio + async def test_resolves_unique_prefixes(self, test_db): + key_a = "aa" * 32 + key_b = "bb" * 32 + await ContactRepository.upsert({"public_key": key_a, "name": "Alice", "type": 1}) + await ContactRepository.upsert({"public_key": key_b, "name": "Bob", "type": 1}) + + result = await ContactRepository.resolve_prefixes(["aa", "bb"]) + assert "aa" in result + assert "bb" in result + assert result["aa"].public_key == key_a + assert result["bb"].public_key == key_b + + @pytest.mark.asyncio + async def test_omits_ambiguous_prefixes(self, test_db): + key_a = "aa" + "11" * 31 + key_b = "aa" + "22" * 31 + await ContactRepository.upsert({"public_key": key_a, "name": "A1", "type": 1}) + await ContactRepository.upsert({"public_key": key_b, "name": "A2", "type": 1}) + + result = await ContactRepository.resolve_prefixes(["aa"]) + assert "aa" not in result # ambiguous — two matches + + @pytest.mark.asyncio + async def test_empty_prefixes_returns_empty(self, test_db): + result = await ContactRepository.resolve_prefixes([]) + assert result == {} + + class TestAppSettingsRepository: """Test AppSettingsRepository parsing and migration edge cases."""