mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 05:45:11 +02:00
Contact info pane
This commit is contained in:
@@ -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 |
|
||||
|
||||
+7
-2
@@ -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:
|
||||
|
||||
+24
-8
@@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+13
-1
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+49
-6
@@ -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
|
||||
|
||||
+26
-7
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+181
-52
@@ -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
|
||||
|
||||
+98
-17
@@ -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")
|
||||
|
||||
+12
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(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 = (
|
||||
<Sidebar
|
||||
@@ -552,6 +573,7 @@ export function App() {
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onDeleteChannel={handleDeleteChannel}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onOpenContactInfo={handleOpenContactInfo}
|
||||
/>
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
@@ -569,6 +591,7 @@ export function App() {
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={handleOpenContactInfo}
|
||||
/>
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
@@ -692,6 +715,16 @@ export function App() {
|
||||
onCreateHashtagChannel={handleCreateHashtagChannel}
|
||||
/>
|
||||
|
||||
<ContactInfoPane
|
||||
contactKey={infoPaneContactKey}
|
||||
onClose={handleCloseContactInfo}
|
||||
contacts={contacts}
|
||||
config={config}
|
||||
favorites={favorites}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onNavigateToChannel={handleNavigateToChannel}
|
||||
/>
|
||||
|
||||
<Toaster position="top-right" />
|
||||
</div>
|
||||
);
|
||||
|
||||
+7
-4
@@ -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<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
|
||||
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
|
||||
fetchJson<RepeaterAdvertPathSummary[]>(
|
||||
fetchJson<ContactAdvertPathSummary[]>(
|
||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||
),
|
||||
getContactAdvertPaths: (publicKey: string, limit = 10) =>
|
||||
fetchJson<RepeaterAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||
getContactDetail: (publicKey: string) =>
|
||||
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<span className="flex-shrink-0 font-semibold text-base">
|
||||
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
onClick={() => onOpenContactInfo(conversation.id)}
|
||||
title="View contact info"
|
||||
>
|
||||
<ContactAvatar
|
||||
name={conversation.name}
|
||||
publicKey={conversation.id}
|
||||
size={28}
|
||||
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
|
||||
clickable
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`flex-shrink-0 font-semibold text-base ${conversation.type === 'contact' && onOpenContactInfo ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
onClick={
|
||||
conversation.type === 'contact' && onOpenContactInfo
|
||||
? () => onOpenContactInfo(conversation.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
conversation.name !== 'Public'
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
|
||||
className={`flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none${clickable ? ' cursor-pointer' : ''}`}
|
||||
style={{
|
||||
backgroundColor: avatar.background,
|
||||
color: avatar.textColor,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
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<ContactDetail | null>(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 (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-[400px] p-0 flex flex-col">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Contact Info</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !detail ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : contact ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-border">
|
||||
<div className="flex items-start gap-4">
|
||||
<ContactAvatar
|
||||
name={contact.name}
|
||||
publicKey={contact.public_key}
|
||||
size={56}
|
||||
contactType={contact.type}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
</h2>
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(contact.public_key);
|
||||
toast.success('Public key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{contact.public_key}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||
</span>
|
||||
{contact.on_radio && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
{contact.last_seen && (
|
||||
<InfoItem label="Last Seen" value={formatTime(contact.last_seen)} />
|
||||
)}
|
||||
{contact.first_seen && (
|
||||
<InfoItem label="First Heard" value={formatTime(contact.first_seen)} />
|
||||
)}
|
||||
{contact.last_contacted && (
|
||||
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
|
||||
)}
|
||||
{distFromUs !== null && (
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
||||
)}
|
||||
{contact.last_path_len >= 0 && (
|
||||
<InfoItem
|
||||
label="Hops"
|
||||
value={
|
||||
contact.last_path_len === 0
|
||||
? 'Direct'
|
||||
: `${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{contact.last_path_len === -1 && <InfoItem label="Routing" value="Flood" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPS */}
|
||||
{isValidLocation(contact.lat, contact.lon) && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Location</SectionLabel>
|
||||
<span
|
||||
className="text-sm font-mono cursor-pointer hover:text-primary hover:underline transition-colors"
|
||||
onClick={() => {
|
||||
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)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorite toggle */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleFavorite('contact', contact.public_key)}
|
||||
>
|
||||
{isFavorite(favorites, 'contact', contact.public_key) ? (
|
||||
<>
|
||||
<span className="text-amber-400 text-lg">★</span>
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{detail && detail.name_history.length > 1 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Stats */}
|
||||
{detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Messages</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{detail.dm_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Direct Messages"
|
||||
value={detail.dm_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
{detail.channel_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Channel Messages"
|
||||
value={detail.channel_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most Active Rooms */}
|
||||
{detail && detail.most_active_rooms.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.most_active_rooms.map((room) => (
|
||||
<div
|
||||
key={room.channel_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advert Info */}
|
||||
{detail && detail.advert_frequency !== null && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Advert Observations</SectionLabel>
|
||||
<p className="text-sm">{detail.advert_frequency.toFixed(1)} observations/hour</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{detail && detail.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.nearest_repeaters.map((r) => (
|
||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{r.path_len === 0
|
||||
? 'direct'
|
||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||
· {r.heard_count}x
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advert Paths */}
|
||||
{detail && detail.advert_paths.length > 0 && (
|
||||
<div className="px-5 py-3">
|
||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.advert_paths.map((p) => (
|
||||
<div
|
||||
key={p.path + p.first_seen}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{p.path || '(direct)'}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Contact not found
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<p className="font-medium text-sm leading-tight">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
@@ -466,7 +468,20 @@ export function MessageList({
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
|
||||
<span
|
||||
onClick={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:')
|
||||
? () => onOpenContactInfo(avatarKey)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable={!!onOpenContactInfo && !avatarKey.startsWith('name:')}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<string, RepeaterAdvertPathSummary['paths']>();
|
||||
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
|
||||
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<RepeaterAdvertPathSummary[]>([]);
|
||||
const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState<ContactAdvertPathSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -282,6 +282,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
on_radio: true,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+36
-4
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
+16
-16
@@ -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")
|
||||
|
||||
+187
-23
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user