fix(contacts): sync device↔DB contacts, restore contact cache

- get_contacts_with_last_seen() reads from mc.contacts (device firmware)
  instead of DB, so /api/contacts/detailed returns only device contacts
- _sync_contacts_to_db() now bidirectional: downgrades stale 'device'
  contacts to 'advert' (cache-only) when not on device anymore
- delete_contact() sets source='advert' (cache) instead of 'deleted',
  keeping contacts visible in @mentions and cache filter
- get_contacts() returns all contacts (no 'deleted' filter needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-07 13:10:13 +01:00
parent b516d4e370
commit 6c34ce85d8
3 changed files with 47 additions and 22 deletions

View File

@@ -116,7 +116,7 @@ class Database:
def get_contacts(self) -> List[Dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM contacts WHERE source != 'deleted' ORDER BY last_seen DESC"
"SELECT * FROM contacts ORDER BY last_seen DESC"
).fetchall()
return [dict(r) for r in rows]
@@ -147,18 +147,33 @@ class Database:
return dict(row) if row else None
def delete_contact(self, public_key: str) -> bool:
"""Soft-delete: mark as 'deleted' instead of removing.
"""Move contact to cache (source='advert') instead of deleting.
Keeps the row so FK references in direct_messages stay intact.
upsert_contact() overwrites source on re-add, auto-undeleting.
Contact stays visible in cache and @mentions but not in device list.
upsert_contact() overwrites source on re-add (back to 'device').
"""
with self._connect() as conn:
cursor = conn.execute(
"UPDATE contacts SET source = 'deleted', lastmod = datetime('now') WHERE public_key = ?",
"UPDATE contacts SET source = 'advert', lastmod = datetime('now') WHERE public_key = ?",
(public_key.lower(),)
)
return cursor.rowcount > 0
def downgrade_stale_device_contacts(self, active_device_keys: set) -> int:
"""Downgrade contacts marked 'device' that are no longer on the device."""
with self._connect() as conn:
all_device = conn.execute(
"SELECT public_key FROM contacts WHERE source = 'device'"
).fetchall()
stale_keys = [r['public_key'] for r in all_device
if r['public_key'] not in active_device_keys]
if stale_keys:
conn.executemany(
"UPDATE contacts SET source = 'advert', lastmod = datetime('now') WHERE public_key = ?",
[(k,) for k in stale_keys]
)
return len(stale_keys)
def set_contact_protected(self, public_key: str, protected: bool) -> bool:
with self._connect() as conn:
cursor = conn.execute(

View File

@@ -268,11 +268,15 @@ class DeviceManager:
logger.debug(f"Subscribed to {event_type.value}")
def _sync_contacts_to_db(self):
"""Sync device contacts to database."""
"""Sync device contacts to database (bidirectional).
- Upserts device contacts with source='device'
- Downgrades DB contacts marked 'device' that are no longer on device to 'advert'
"""
if not self.mc or not self.mc.contacts:
return
count = 0
device_keys = set()
for pubkey, contact in self.mc.contacts.items():
# last_advert from meshcore is Unix timestamp (int) or None
last_adv = contact.get('last_advert')
@@ -290,8 +294,13 @@ class DeviceManager:
adv_lon=contact.get('adv_lon'),
source='device',
)
count += 1
logger.info(f"Synced {count} contacts from device to database")
device_keys.add(pubkey.lower())
# Downgrade stale 'device' contacts to 'advert' (cache-only)
stale = self.db.downgrade_stale_device_contacts(device_keys)
if stale:
logger.info(f"Downgraded {stale} stale device contacts to cache")
logger.info(f"Synced {len(device_keys)} contacts from device to database")
def execute(self, coro, timeout: float = 30) -> Any:
"""

View File

@@ -147,24 +147,25 @@ def _parse_last_advert(value) -> int:
def get_contacts_with_last_seen() -> Tuple[bool, Dict[str, Dict], str]:
"""Get contacts with last_advert timestamps from DB."""
"""Get contacts actually on the device firmware (from mc.contacts)."""
try:
dm = _get_dm()
contacts = dm.db.get_contacts()
if not dm.mc or not dm.mc.contacts:
return True, {}, ""
contacts_dict = {}
for c in contacts:
pk = c.get('public_key', '')
for pk, contact in dm.mc.contacts.items():
last_adv = contact.get('last_advert')
contacts_dict[pk] = {
'public_key': pk,
'type': c.get('type', 1),
'flags': c.get('flags', 0),
'out_path_len': c.get('out_path_len', -1),
'out_path': c.get('out_path', ''),
'adv_name': c.get('name', ''),
'last_advert': _parse_last_advert(c.get('last_advert')),
'adv_lat': c.get('adv_lat', 0.0),
'adv_lon': c.get('adv_lon', 0.0),
'lastmod': c.get('lastmod', ''),
'type': contact.get('type', 1),
'flags': contact.get('flags', 0),
'out_path_len': contact.get('out_path_len', -1),
'out_path': contact.get('out_path', ''),
'adv_name': contact.get('adv_name', contact.get('name', '')),
'last_advert': int(last_adv) if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0 else 0,
'adv_lat': contact.get('adv_lat', 0.0),
'adv_lon': contact.get('adv_lon', 0.0),
'lastmod': '',
}
return True, contacts_dict, ""
except Exception as e: