From 6c34ce85d8e76da4d65111508944f2a98b50dfc9 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sat, 7 Mar 2026 13:10:13 +0100 Subject: [PATCH] =?UTF-8?q?fix(contacts):=20sync=20device=E2=86=94DB=20con?= =?UTF-8?q?tacts,=20restore=20contact=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/database.py | 25 ++++++++++++++++++++----- app/device_manager.py | 17 +++++++++++++---- app/meshcore/cli.py | 27 ++++++++++++++------------- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/app/database.py b/app/database.py index b11b626..a0eb447 100644 --- a/app/database.py +++ b/app/database.py @@ -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( diff --git a/app/device_manager.py b/app/device_manager.py index 7798578..3d4ebab 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -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: """ diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 26728d5..3f835ba 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -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: