diff --git a/app/database.py b/app/database.py
index 4f876c7..33e974e 100644
--- a/app/database.py
+++ b/app/database.py
@@ -163,6 +163,15 @@ class Database:
)
return cursor.rowcount > 0
+ def hard_delete_contact(self, public_key: str) -> bool:
+ """Permanently delete a contact from the database."""
+ with self._connect() as conn:
+ cursor = conn.execute(
+ "DELETE FROM contacts 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:
diff --git a/app/device_manager.py b/app/device_manager.py
index da035df..2097a9a 100644
--- a/app/device_manager.py
+++ b/app/device_manager.py
@@ -629,6 +629,34 @@ class DeviceManager:
source='advert',
)
+ # If manual mode: add cache-only contacts to pending list
+ # (meshcore may fire ADVERTISEMENT instead of NEW_CONTACT for
+ # contacts already in mc.pending_contacts or after restart)
+ if (self._is_manual_approval_enabled()
+ and pubkey not in (self.mc.contacts or {})
+ and pubkey not in (self.mc.pending_contacts or {})
+ and not self.db.is_contact_ignored(pubkey)
+ and not self.db.is_contact_blocked(pubkey)):
+ # Add to pending_contacts so it shows in pending list
+ if self.mc.pending_contacts is None:
+ self.mc.pending_contacts = {}
+ self.mc.pending_contacts[pubkey] = {
+ 'public_key': pubkey,
+ 'adv_name': name,
+ 'name': name,
+ 'type': adv_type,
+ 'adv_lat': adv_lat,
+ 'adv_lon': adv_lon,
+ 'last_advert': int(time.time()),
+ }
+ logger.info(f"Cache contact added to pending (advert): {name} ({pubkey[:8]}...)")
+ if self.socketio:
+ self.socketio.emit('pending_contact', {
+ 'public_key': pubkey,
+ 'name': name,
+ 'type': adv_type,
+ }, namespace='/chat')
+
logger.info(f"Advert from '{name}' ({pubkey[:8]}...) type={adv_type}")
except Exception as e:
@@ -1164,7 +1192,7 @@ class DeviceManager:
try:
self.execute(self.mc.commands.remove_contact(pubkey))
- self.db.delete_contact(pubkey) # soft-delete: sets source='deleted'
+ self.db.delete_contact(pubkey) # soft-delete: sets source='advert'
# Also remove from in-memory contacts cache
if self.mc.contacts and pubkey in self.mc.contacts:
del self.mc.contacts[pubkey]
@@ -1173,6 +1201,20 @@ class DeviceManager:
logger.error(f"Failed to delete contact: {e}")
return {'success': False, 'error': str(e)}
+ def delete_cached_contact(self, pubkey: str) -> Dict:
+ """Hard-delete a cache-only contact from the database."""
+ try:
+ # Don't delete if contact is on device
+ if self.mc and self.mc.contacts and pubkey in self.mc.contacts:
+ return {'success': False, 'error': 'Contact is on device, use delete_contact instead'}
+ deleted = self.db.hard_delete_contact(pubkey)
+ if deleted:
+ return {'success': True, 'message': 'Cache contact deleted'}
+ return {'success': False, 'error': 'Contact not found in cache'}
+ except Exception as e:
+ logger.error(f"Failed to delete cached contact: {e}")
+ return {'success': False, 'error': str(e)}
+
def reset_path(self, pubkey: str) -> Dict:
"""Reset path to a contact."""
if not self.is_connected:
@@ -1322,6 +1364,19 @@ class DeviceManager:
try:
contact = (self.mc.pending_contacts or {}).get(pubkey)
+ # Also check DB cache for contacts not in meshcore's pending list
+ if not contact:
+ db_contact = self.db.get_contact(pubkey)
+ if db_contact and db_contact.get('source') == 'advert':
+ contact = {
+ 'public_key': pubkey,
+ 'name': db_contact.get('name', ''),
+ 'adv_name': db_contact.get('name', ''),
+ 'type': db_contact.get('type', 0),
+ 'adv_lat': db_contact.get('adv_lat'),
+ 'adv_lon': db_contact.get('adv_lon'),
+ 'last_advert': db_contact.get('last_advert'),
+ }
if not contact:
return {'success': False, 'error': 'Contact not in pending list'}
@@ -1373,6 +1428,11 @@ class DeviceManager:
removed = self.mc.pending_contacts.pop(pubkey, None)
if removed:
return {'success': True, 'message': 'Contact rejected'}
+ # Also check DB cache - remove cache-only contacts on reject
+ db_contact = self.db.get_contact(pubkey)
+ if db_contact and db_contact.get('source') == 'advert':
+ self.db.hard_delete_contact(pubkey)
+ return {'success': True, 'message': 'Contact rejected'}
return {'success': False, 'error': 'Contact not in pending list'}
except Exception as e:
logger.error(f"Failed to reject contact: {e}")
diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py
index 3f835ba..2d87b14 100644
--- a/app/meshcore/cli.py
+++ b/app/meshcore/cli.py
@@ -224,6 +224,19 @@ def delete_contact(selector: str) -> Tuple[bool, str]:
return False, str(e)
+def delete_cached_contact(public_key: str) -> Tuple[bool, str]:
+ """Hard-delete a cache-only contact from the database."""
+ if not public_key or not public_key.strip():
+ return False, "Public key is required"
+
+ try:
+ dm = _get_dm()
+ result = dm.delete_cached_contact(public_key.strip())
+ return result['success'], result.get('message', result.get('error', ''))
+ except Exception as e:
+ return False, str(e)
+
+
def clean_inactive_contacts(hours: int = 48) -> Tuple[bool, str]:
"""Remove contacts inactive for specified hours. Simplified in v2."""
# TODO: implement time-based cleanup via database query
diff --git a/app/routes/api.py b/app/routes/api.py
index eac323b..b453e65 100644
--- a/app/routes/api.py
+++ b/app/routes/api.py
@@ -2494,6 +2494,37 @@ def delete_contact_api():
}), 500
+@api_bp.route('/contacts/cached/delete', methods=['POST'])
+def delete_cached_contact_api():
+ """Delete a cache-only contact from the database."""
+ try:
+ data = request.get_json()
+
+ if not data or 'public_key' not in data:
+ return jsonify({
+ 'success': False,
+ 'error': 'Missing required field: public_key'
+ }), 400
+
+ public_key = data['public_key']
+ if not isinstance(public_key, str) or not public_key.strip():
+ return jsonify({
+ 'success': False,
+ 'error': 'public_key must be a non-empty string'
+ }), 400
+
+ success, message = cli.delete_cached_contact(public_key)
+
+ if success:
+ return jsonify({'success': True, 'message': message}), 200
+ else:
+ return jsonify({'success': False, 'error': message}), 500
+
+ except Exception as e:
+ logger.error(f"Error deleting cached contact: {e}")
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
@api_bp.route('/contacts/protected', methods=['GET'])
def get_protected_contacts_api():
"""
diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js
index c9a4fe7..f92cabf 100644
--- a/app/static/js/contacts.js
+++ b/app/static/js/contacts.js
@@ -2193,6 +2193,15 @@ function createExistingContactCard(contact, index) {
actionsDiv.appendChild(deleteBtn);
}
+ // Delete button for cache-only contacts
+ if (contact.on_device === false) {
+ const deleteCacheBtn = document.createElement('button');
+ deleteCacheBtn.className = 'btn btn-sm btn-outline-danger';
+ deleteCacheBtn.innerHTML = ' Delete';
+ deleteCacheBtn.onclick = () => showDeleteModal(contact);
+ actionsDiv.appendChild(deleteCacheBtn);
+ }
+
// Ignore/Block/Unignore/Unblock buttons
if (contact.is_blocked) {
const unblockBtn = document.createElement('button');
@@ -2317,17 +2326,19 @@ async function confirmDelete() {
}
try {
- // Use contact name for deletion (meshcli remove_contact only works with name)
- const selector = contactToDelete.name;
+ // Use different endpoint for cache-only vs device contacts
+ const isCacheOnly = contactToDelete.on_device === false;
+ const url = isCacheOnly ? '/api/contacts/cached/delete' : '/api/contacts/delete';
+ const body = isCacheOnly
+ ? { public_key: contactToDelete.public_key }
+ : { selector: contactToDelete.public_key };
- const response = await fetch('/api/contacts/delete', {
+ const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
- body: JSON.stringify({
- selector: selector
- })
+ body: JSON.stringify(body)
});
const data = await response.json();