From 8cc67f77d53e502da9ac045fb963cc750a0acc47 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 22 Mar 2026 21:20:51 +0100 Subject: [PATCH] feat(dm): add multi-path management and per-contact no-flood toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `contact_paths` table for storing multiple user-configured paths per contact - New `no_auto_flood` column on contacts to prevent automatic DIRECT→FLOOD reset - Path rotation during DM retry: cycles through configured paths before optional flood fallback - REST API for path CRUD, reorder, reset-to-flood, repeater listing - Path management UI in Contact Info modal: add/delete/reorder paths, repeater picker with uniqueness warnings, hash size selector (1B/2B/3B) - "No Flood" per-contact toggle in modal footer Co-Authored-By: Claude Opus 4.6 --- app/database.py | 146 +++++++++++++- app/device_manager.py | 195 ++++++++++++++----- app/routes/api.py | 214 +++++++++++++++++++++ app/schema.sql | 13 ++ app/static/js/dm.js | 431 ++++++++++++++++++++++++++++++++++++++++++ app/templates/dm.html | 142 +++++++++++++- 6 files changed, 1091 insertions(+), 50 deletions(-) diff --git a/app/database.py b/app/database.py index 5ad9f59..1e46361 100644 --- a/app/database.py +++ b/app/database.py @@ -33,8 +33,17 @@ class Database: conn.execute("PRAGMA foreign_keys=ON") schema_sql = SCHEMA_FILE.read_text(encoding='utf-8') conn.executescript(schema_sql) + self._run_migrations(conn) logger.info(f"Database initialized: {self.db_path}") + def _run_migrations(self, conn): + """Run schema migrations for columns added after initial release.""" + # Check if contacts.no_auto_flood column exists + columns = {r[1] for r in conn.execute("PRAGMA table_info(contacts)").fetchall()} + if 'no_auto_flood' not in columns: + conn.execute("ALTER TABLE contacts ADD COLUMN no_auto_flood INTEGER DEFAULT 0") + logger.info("Migration: added contacts.no_auto_flood column") + @contextmanager def _connect(self): """Yield a connection with auto-commit/rollback.""" @@ -751,6 +760,141 @@ class Database: kwargs.get('path_len')) ) + # ================================================================ + # Contact Paths (user-configured paths for DM routing) + # ================================================================ + + def get_contact_paths(self, contact_pubkey: str) -> List[Dict]: + """Get all configured paths for a contact, ordered by sort_order.""" + with self._connect() as conn: + rows = conn.execute( + """SELECT * FROM contact_paths + WHERE contact_pubkey = ? + ORDER BY sort_order ASC, id ASC""", + (contact_pubkey.lower(),) + ).fetchall() + return [dict(r) for r in rows] + + def add_contact_path(self, contact_pubkey: str, path_hex: str, + hash_size: int = 1, label: str = '', + is_primary: bool = False) -> int: + """Add a new path for a contact. Returns the new row ID.""" + pk = contact_pubkey.lower() + with self._connect() as conn: + if is_primary: + conn.execute( + "UPDATE contact_paths SET is_primary = 0 WHERE contact_pubkey = ?", + (pk,) + ) + # Auto-assign sort_order as max+1 + row = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM contact_paths WHERE contact_pubkey = ?", + (pk,) + ).fetchone() + next_order = row['next_order'] if row else 0 + cursor = conn.execute( + """INSERT INTO contact_paths + (contact_pubkey, path_hex, hash_size, label, is_primary, sort_order) + VALUES (?, ?, ?, ?, ?, ?)""", + (pk, path_hex, hash_size, label, 1 if is_primary else 0, next_order) + ) + return cursor.lastrowid + + def update_contact_path(self, path_id: int, **kwargs) -> bool: + """Update fields on a contact path (path_hex, hash_size, label, is_primary).""" + allowed = {'path_hex', 'hash_size', 'label', 'is_primary', 'sort_order'} + updates = {k: v for k, v in kwargs.items() if k in allowed} + if not updates: + return False + with self._connect() as conn: + # If setting as primary, clear others first + if updates.get('is_primary'): + row = conn.execute( + "SELECT contact_pubkey FROM contact_paths WHERE id = ?", (path_id,) + ).fetchone() + if row: + conn.execute( + "UPDATE contact_paths SET is_primary = 0 WHERE contact_pubkey = ?", + (row['contact_pubkey'],) + ) + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [path_id] + cursor = conn.execute( + f"UPDATE contact_paths SET {set_clause} WHERE id = ?", values + ) + return cursor.rowcount > 0 + + def delete_contact_path(self, path_id: int) -> bool: + """Delete a single configured path.""" + with self._connect() as conn: + cursor = conn.execute( + "DELETE FROM contact_paths WHERE id = ?", (path_id,) + ) + return cursor.rowcount > 0 + + def delete_all_contact_paths(self, contact_pubkey: str) -> int: + """Delete all configured paths for a contact. Returns count deleted.""" + with self._connect() as conn: + cursor = conn.execute( + "DELETE FROM contact_paths WHERE contact_pubkey = ?", + (contact_pubkey.lower(),) + ) + return cursor.rowcount + + def reorder_contact_paths(self, contact_pubkey: str, path_ids: List[int]) -> bool: + """Set sort_order based on the order of IDs in the list.""" + pk = contact_pubkey.lower() + with self._connect() as conn: + for order, pid in enumerate(path_ids): + conn.execute( + "UPDATE contact_paths SET sort_order = ? WHERE id = ? AND contact_pubkey = ?", + (order, pid, pk) + ) + return True + + def get_primary_contact_path(self, contact_pubkey: str) -> Optional[Dict]: + """Get the primary path (or first by sort_order if none marked primary).""" + with self._connect() as conn: + row = conn.execute( + """SELECT * FROM contact_paths + WHERE contact_pubkey = ? + ORDER BY is_primary DESC, sort_order ASC + LIMIT 1""", + (contact_pubkey.lower(),) + ).fetchone() + return dict(row) if row else None + + def set_contact_no_auto_flood(self, contact_pubkey: str, value: bool) -> bool: + """Set the no_auto_flood flag for a contact.""" + with self._connect() as conn: + cursor = conn.execute( + "UPDATE contacts SET no_auto_flood = ?, lastmod = datetime('now') WHERE public_key = ?", + (1 if value else 0, contact_pubkey.lower()) + ) + return cursor.rowcount > 0 + + def get_contact_no_auto_flood(self, contact_pubkey: str) -> bool: + """Get the no_auto_flood flag for a contact.""" + with self._connect() as conn: + row = conn.execute( + "SELECT no_auto_flood FROM contacts WHERE public_key = ?", + (contact_pubkey.lower(),) + ).fetchone() + return bool(row['no_auto_flood']) if row and row['no_auto_flood'] else False + + def get_repeater_contacts(self) -> List[Dict]: + """Get all repeater contacts (type=1) from DB, including ignored ones.""" + with self._connect() as conn: + rows = conn.execute( + """SELECT c.public_key, c.name, c.last_advert, c.adv_lat, c.adv_lon, + CASE WHEN ic.public_key IS NOT NULL THEN 1 ELSE 0 END AS is_ignored + FROM contacts c + LEFT JOIN ignored_contacts ic ON c.public_key = ic.public_key + WHERE c.type = 1 + ORDER BY c.name ASC""" + ).fetchall() + return [dict(r) for r in rows] + # ================================================================ # Advertisements # ================================================================ @@ -861,7 +1005,7 @@ class Database: """Get row counts for all tables.""" tables = ['device', 'contacts', 'channels', 'channel_messages', 'direct_messages', 'acks', 'echoes', 'paths', - 'advertisements', 'read_status'] + 'contact_paths', 'advertisements', 'read_status'] stats = {} with self._connect() as conn: for table in tables: diff --git a/app/device_manager.py b/app/device_manager.py index a1a8976..e393414 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -1083,14 +1083,72 @@ class DeviceManager: logger.error(f"Failed to send DM: {e}") return {'success': False, 'error': str(e)} + async def _change_path_async(self, contact, path_hex: str, hash_size: int = 1): + """Change contact path on device with proper hash_size encoding.""" + hop_count = len(path_hex) // (hash_size * 2) + encoded_path_len = ((hash_size - 1) << 6) | hop_count + # Set encoded values on contact dict before calling update_contact + contact['out_path'] = path_hex + contact['out_path_len'] = encoded_path_len + await self.mc.commands.update_contact(contact) + + async def _restore_primary_path(self, contact, contact_pubkey: str): + """Restore the primary configured path on the device after retry exhaustion.""" + try: + primary = self.db.get_primary_contact_path(contact_pubkey) + if primary: + await self._change_path_async(contact, primary['path_hex'], primary['hash_size']) + logger.info(f"Restored primary path for {contact_pubkey[:12]}") + else: + logger.debug(f"No primary path to restore for {contact_pubkey[:12]}") + except Exception as e: + logger.warning(f"Failed to restore primary path for {contact_pubkey[:12]}: {e}") + + async def _dm_retry_send_and_wait(self, contact, text, timestamp, attempt, + dm_id, suggested_timeout, min_wait): + """Send a DM retry attempt and wait for ACK. Returns True if delivered.""" + from meshcore.events import EventType + + try: + result = await self.mc.commands.send_msg( + contact, text, timestamp=timestamp, attempt=attempt + ) + except Exception as e: + logger.warning(f"DM retry {attempt}: send error: {e}") + return False + + if result.type == EventType.ERROR: + logger.warning(f"DM retry {attempt}: device error") + return False + + retry_ack = _to_str(result.payload.get('expected_ack')) + if retry_ack: + self._pending_acks[retry_ack] = dm_id + new_timeout = result.payload.get('suggested_timeout', suggested_timeout) + wait_s = max(new_timeout / 1000 * 1.2, min_wait) + + ack_event = await self.mc.dispatcher.wait_for_event( + EventType.ACK, + attribute_filters={"code": retry_ack}, + timeout=wait_s + ) + if ack_event: + self._confirm_delivery(dm_id, retry_ack, ack_event) + return True + + return False + async def _dm_retry_task(self, dm_id: int, contact, text: str, timestamp: int, initial_ack: str, suggested_timeout: int): """Background retry with same timestamp for dedup on receiver. - Strategy depends on whether contact has a known DIRECT path: - - DIRECT path known: direct_max_retries DIRECT + direct_flood_retries FLOOD - - No path (FLOOD): flood_max_retries attempts + Strategy (in priority order): + 1. PATH ROTATION: If user-configured paths exist, rotate through them. + 2. DIRECT+FLOOD: If contact has device path, try direct then optionally flood. + 3. FLOOD only: If no path known, flood retries. + + The no_auto_flood per-contact flag prevents automatic DIRECT→FLOOD reset. Settings loaded from app_settings DB table (key: dm_retry_settings). """ from meshcore.events import EventType @@ -1104,22 +1162,20 @@ class DeviceManager: saved = self.db.get_setting_json('dm_retry_settings', {}) cfg = {**_defaults, **(saved or {})} + contact_pubkey = contact.get('public_key', '').lower() has_path = contact.get('out_path_len', -1) > 0 - if has_path: - # +1 counts the initial send - max_attempts = cfg['direct_max_retries'] + cfg['direct_flood_retries'] + 1 - flood_at = cfg['direct_max_retries'] + 1 - min_wait = float(cfg['direct_interval']) - else: - max_attempts = cfg['flood_max_retries'] + 1 - flood_at = None - min_wait = float(cfg['flood_interval']) + # Load user-configured paths and no_auto_flood flag + configured_paths = self.db.get_contact_paths(contact_pubkey) if contact_pubkey else [] + no_auto_flood = self.db.get_contact_no_auto_flood(contact_pubkey) if contact_pubkey else False + min_wait = float(cfg['direct_interval']) if has_path else float(cfg['flood_interval']) wait_s = max(suggested_timeout / 1000 * 1.2, min_wait) - mode = "DIRECT" if has_path else "FLOOD" + + mode = "PATH_ROTATION" if configured_paths else ("DIRECT" if has_path else "FLOOD") logger.info(f"DM retry task started: dm_id={dm_id}, mode={mode}, " - f"max_attempts={max_attempts}, wait={wait_s:.0f}s") + f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, " + f"wait={wait_s:.0f}s") # Wait for ACK on initial send if initial_ack: @@ -1132,47 +1188,94 @@ class DeviceManager: self._confirm_delivery(dm_id, initial_ack, ack_event) return - # Retry with same timestamp, incrementing attempt - for attempt in range(1, max_attempts): - if flood_at and attempt >= flood_at: - # Switch to FLOOD mode: reset path and use flood interval + attempt = 0 # Global attempt counter (0 = initial send already done) + + # ── Strategy 1: PATH ROTATION ── + if configured_paths: + retries_per_path = max(1, cfg['direct_max_retries']) + min_wait = float(cfg['direct_interval']) + + for path_info in configured_paths: + # Switch to this path on the device + try: + await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size']) + logger.info(f"DM retry: switched to path '{path_info.get('label', '')}' " + f"({path_info['path_hex']})") + except Exception as e: + logger.warning(f"DM retry: failed to switch path: {e}") + continue + + # Try sending on this path + for _ in range(retries_per_path): + attempt += 1 + if await self._dm_retry_send_and_wait( + contact, text, timestamp, attempt, dm_id, + suggested_timeout, min_wait + ): + # Delivered! Restore primary path + await self._restore_primary_path(contact, contact_pubkey) + return + + # All configured paths exhausted + if not no_auto_flood: + # Fall back to FLOOD min_wait = float(cfg['flood_interval']) - wait_s = max(suggested_timeout / 1000 * 1.2, min_wait) try: await self.mc.commands.reset_path(contact) - logger.info(f"DM retry {attempt}: reset path to flood") + logger.info("DM retry: all paths exhausted, falling back to FLOOD") except Exception: pass + for _ in range(cfg['flood_max_retries']): + attempt += 1 + if await self._dm_retry_send_and_wait( + contact, text, timestamp, attempt, dm_id, + suggested_timeout, min_wait + ): + await self._restore_primary_path(contact, contact_pubkey) + return - try: - result = await self.mc.commands.send_msg( - contact, text, timestamp=timestamp, attempt=attempt - ) - except Exception as e: - logger.warning(f"DM retry {attempt}/{max_attempts}: send error: {e}") - continue + # Restore primary path regardless of outcome + await self._restore_primary_path(contact, contact_pubkey) - if result.type == EventType.ERROR: - logger.warning(f"DM retry {attempt}/{max_attempts}: device error") - continue - - retry_ack = _to_str(result.payload.get('expected_ack')) - if retry_ack: - self._pending_acks[retry_ack] = dm_id - new_timeout = result.payload.get('suggested_timeout', suggested_timeout) - wait_s = max(new_timeout / 1000 * 1.2, min_wait) - - if retry_ack: - ack_event = await self.mc.dispatcher.wait_for_event( - EventType.ACK, - attribute_filters={"code": retry_ack}, - timeout=wait_s - ) - if ack_event: - self._confirm_delivery(dm_id, retry_ack, ack_event) + # ── Strategy 2: DIRECT + optional FLOOD (no configured paths) ── + elif has_path: + # Direct retries + for _ in range(cfg['direct_max_retries']): + attempt += 1 + if await self._dm_retry_send_and_wait( + contact, text, timestamp, attempt, dm_id, + suggested_timeout, float(cfg['direct_interval']) + ): return - logger.warning(f"DM retry exhausted ({max_attempts} {mode} attempts) for dm_id={dm_id}") + # Switch to flood (unless no_auto_flood) + if not no_auto_flood: + min_wait = float(cfg['flood_interval']) + try: + await self.mc.commands.reset_path(contact) + logger.info("DM retry: direct exhausted, resetting to flood") + except Exception: + pass + for _ in range(cfg['direct_flood_retries']): + attempt += 1 + if await self._dm_retry_send_and_wait( + contact, text, timestamp, attempt, dm_id, + suggested_timeout, min_wait + ): + return + + # ── Strategy 3: FLOOD only ── + else: + for _ in range(cfg['flood_max_retries']): + attempt += 1 + if await self._dm_retry_send_and_wait( + contact, text, timestamp, attempt, dm_id, + suggested_timeout, float(cfg['flood_interval']) + ): + return + + logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, mode={mode}) " + f"for dm_id={dm_id}") # Keep pending acks for grace period so late ACKs can still be matched self._retry_tasks.pop(dm_id, None) await asyncio.sleep(cfg['grace_period']) diff --git a/app/routes/api.py b/app/routes/api.py index 2a2a643..e2ca584 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2168,6 +2168,220 @@ def set_auto_retry_config(): return jsonify({'success': False, 'error': str(e)}), 500 +## ================================================================ +# Contact Paths (user-configured DM routing paths) +# ================================================================ + +@api_bp.route('/contacts//paths', methods=['GET']) +def get_contact_paths(pubkey): + """List all configured paths for a contact.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + paths = db.get_contact_paths(pubkey) + return jsonify({'success': True, 'paths': paths}), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//paths', methods=['POST']) +def add_contact_path(pubkey): + """Add a new configured path for a contact.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'Missing JSON body'}), 400 + + path_hex = data.get('path_hex', '').strip() + hash_size = data.get('hash_size', 1) + label = data.get('label', '').strip() + is_primary = bool(data.get('is_primary', False)) + + # Validate path_hex + if not path_hex: + return jsonify({'success': False, 'error': 'path_hex is required'}), 400 + if len(path_hex) % 2 != 0: + return jsonify({'success': False, 'error': 'path_hex must have even length'}), 400 + try: + bytes.fromhex(path_hex) + except ValueError: + return jsonify({'success': False, 'error': 'path_hex must be valid hex'}), 400 + + # Validate hash_size + if hash_size not in (1, 2, 3): + return jsonify({'success': False, 'error': 'hash_size must be 1, 2, or 3'}), 400 + + # Validate hop count + hop_count = len(path_hex) // (hash_size * 2) + max_hops = {1: 64, 2: 32, 3: 21} + if hop_count < 1 or hop_count > max_hops[hash_size]: + return jsonify({'success': False, 'error': f'Hop count {hop_count} exceeds max {max_hops[hash_size]} for {hash_size}-byte hash'}), 400 + + # Validate path_hex length is a multiple of hash_size + if len(path_hex) % (hash_size * 2) != 0: + return jsonify({'success': False, 'error': f'path_hex length must be a multiple of {hash_size * 2} (hash_size={hash_size})'}), 400 + + # Validate label length + if len(label) > 50: + return jsonify({'success': False, 'error': 'Label must be 50 characters or less'}), 400 + + path_id = db.add_contact_path(pubkey, path_hex, hash_size, label, is_primary) + return jsonify({'success': True, 'id': path_id}), 201 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//paths/', methods=['PUT']) +def update_contact_path(pubkey, path_id): + """Update a configured path.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'Missing JSON body'}), 400 + + kwargs = {} + if 'path_hex' in data: + path_hex = data['path_hex'].strip() + if len(path_hex) % 2 != 0: + return jsonify({'success': False, 'error': 'path_hex must have even length'}), 400 + try: + bytes.fromhex(path_hex) + except ValueError: + return jsonify({'success': False, 'error': 'path_hex must be valid hex'}), 400 + kwargs['path_hex'] = path_hex + if 'hash_size' in data: + if data['hash_size'] not in (1, 2, 3): + return jsonify({'success': False, 'error': 'hash_size must be 1, 2, or 3'}), 400 + kwargs['hash_size'] = data['hash_size'] + if 'label' in data: + label = data['label'].strip() + if len(label) > 50: + return jsonify({'success': False, 'error': 'Label must be 50 characters or less'}), 400 + kwargs['label'] = label + if 'is_primary' in data: + kwargs['is_primary'] = 1 if data['is_primary'] else 0 + + if not kwargs: + return jsonify({'success': False, 'error': 'No valid fields to update'}), 400 + + if db.update_contact_path(path_id, **kwargs): + return jsonify({'success': True}), 200 + return jsonify({'success': False, 'error': 'Path not found'}), 404 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//paths/', methods=['DELETE']) +def delete_contact_path(pubkey, path_id): + """Delete a single configured path.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + if db.delete_contact_path(path_id): + return jsonify({'success': True}), 200 + return jsonify({'success': False, 'error': 'Path not found'}), 404 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//paths/reorder', methods=['POST']) +def reorder_contact_paths(pubkey): + """Reorder configured paths. Body: {path_ids: [3, 1, 2]}""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + data = request.get_json() + if not data or 'path_ids' not in data: + return jsonify({'success': False, 'error': 'path_ids array required'}), 400 + path_ids = data['path_ids'] + if not isinstance(path_ids, list) or not all(isinstance(i, int) for i in path_ids): + return jsonify({'success': False, 'error': 'path_ids must be an array of integers'}), 400 + db.reorder_contact_paths(pubkey, path_ids) + return jsonify({'success': True}), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//paths/reset_flood', methods=['POST']) +def reset_contact_to_flood(pubkey): + """Reset to FLOOD: clear all configured paths and reset device path.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + # Clear all saved paths from DB + deleted = db.delete_all_contact_paths(pubkey) + # Reset path on device + result = {'success': True, 'paths_deleted': deleted} + try: + dm = cli.get_device_manager() + if dm and dm.is_connected: + dev_result = dm.reset_path(pubkey) + result['device_reset'] = dev_result.get('success', False) + else: + result['device_reset'] = False + result['warning'] = 'Device not connected' + except Exception as e: + result['device_reset'] = False + result['warning'] = f'Device reset failed: {e}' + return jsonify(result), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//no_auto_flood', methods=['GET']) +def get_no_auto_flood(pubkey): + """Get the no_auto_flood flag for a contact.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + value = db.get_contact_no_auto_flood(pubkey) + return jsonify({'success': True, 'no_auto_flood': value}), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts//no_auto_flood', methods=['PUT']) +def set_no_auto_flood(pubkey): + """Set the no_auto_flood flag for a contact.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + data = request.get_json() + if data is None or 'no_auto_flood' not in data: + return jsonify({'success': False, 'error': 'no_auto_flood field required'}), 400 + value = bool(data['no_auto_flood']) + if db.set_contact_no_auto_flood(pubkey, value): + return jsonify({'success': True, 'no_auto_flood': value}), 200 + return jsonify({'success': False, 'error': 'Contact not found'}), 404 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/contacts/repeaters', methods=['GET']) +def get_repeater_contacts(): + """List all repeater contacts (type=1) from DB, including ignored.""" + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + try: + repeaters = db.get_repeater_contacts() + return jsonify({'success': True, 'repeaters': repeaters}), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/chat/settings', methods=['GET']) def get_chat_config(): """Get chat settings.""" diff --git a/app/schema.sql b/app/schema.sql index 595aa12..bc80ae4 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -115,6 +115,18 @@ CREATE TABLE IF NOT EXISTS paths ( received_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- User-configured paths for DM retry rotation +CREATE TABLE IF NOT EXISTS contact_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contact_pubkey TEXT NOT NULL REFERENCES contacts(public_key) ON DELETE CASCADE, + path_hex TEXT NOT NULL DEFAULT '', -- raw hex path bytes (e.g. "5e34e761") + hash_size INTEGER NOT NULL DEFAULT 1, -- bytes per hop: 1, 2, or 3 + label TEXT NOT NULL DEFAULT '', -- friendly label (e.g. "via Zalesie") + is_primary INTEGER NOT NULL DEFAULT 0, -- 1 = priority/default path + sort_order INTEGER NOT NULL DEFAULT 0, -- lower = tried first during rotation + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + -- Advertisements (replaces .adverts.jsonl) CREATE TABLE IF NOT EXISTS advertisements ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -174,6 +186,7 @@ CREATE INDEX IF NOT EXISTS idx_acks_code ON acks(expected_ack); CREATE INDEX IF NOT EXISTS idx_echoes_pkt ON echoes(pkt_payload); CREATE INDEX IF NOT EXISTS idx_adv_pubkey ON advertisements(public_key, timestamp); CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name); +CREATE INDEX IF NOT EXISTS idx_cp_contact ON contact_paths(contact_pubkey, sort_order); -- ============================================================ -- Full-Text Search (FTS5) diff --git a/app/static/js/dm.js b/app/static/js/dm.js index ea6ed9a..66880c5 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -278,6 +278,7 @@ function setupEventListeners() { infoBtn.addEventListener('click', () => { const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal')); populateContactInfoModal(); + loadPathSection(); modal.show(); }); } @@ -1607,3 +1608,433 @@ async function loadAutoRetryConfig() { } }); } + +// ================================================================ +// Path Management +// ================================================================ + +let _repeatersCache = null; + +/** + * Get the current contact's full public key for path API calls. + */ +function getCurrentContactPubkey() { + const contact = findCurrentContact(); + return contact?.public_key || currentConversationId || ''; +} + +/** + * Load and display the path section in Contact Info modal. + */ +async function loadPathSection() { + const section = document.getElementById('dmPathSection'); + const pubkey = getCurrentContactPubkey(); + if (!section || !pubkey) { + if (section) section.style.display = 'none'; + return; + } + + section.style.display = ''; + await renderPathList(pubkey); + await loadNoAutoFloodToggle(pubkey); + setupPathFormHandlers(pubkey); +} + +/** + * Render the list of configured paths for a contact. + */ +async function renderPathList(pubkey) { + const listEl = document.getElementById('dmPathList'); + if (!listEl) return; + + listEl.innerHTML = '
Loading...
'; + + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`); + const data = await response.json(); + if (!data.success || !data.paths.length) { + listEl.innerHTML = '
No paths configured. Use + to add.
'; + return; + } + + listEl.innerHTML = ''; + data.paths.forEach((path, index) => { + const item = document.createElement('div'); + item.className = 'path-list-item' + (path.is_primary ? ' primary' : ''); + + // Format path hex as hop→hop→hop + const chunk = path.hash_size * 2; + const hops = []; + for (let i = 0; i < path.path_hex.length; i += chunk) { + hops.push(path.path_hex.substring(i, i + chunk).toUpperCase()); + } + const pathDisplay = hops.join('→'); + const hashLabel = path.hash_size + 'B'; + + item.innerHTML = ` + ${pathDisplay} + ${hashLabel} + ${path.label ? `${path.label}` : ''} + + + ${index > 0 ? `` : ''} + ${index < data.paths.length - 1 ? `` : ''} + + + `; + listEl.appendChild(item); + }); + + // Attach action handlers + listEl.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const action = btn.dataset.action; + const pathId = parseInt(btn.dataset.id); + + if (action === 'primary') { + await setPathPrimary(pubkey, pathId); + } else if (action === 'delete') { + await deletePathItem(pubkey, pathId); + } else if (action === 'up' || action === 'down') { + await movePathItem(pubkey, data.paths, parseInt(btn.dataset.index), action); + } + }); + }); + } catch (e) { + listEl.innerHTML = '
Failed to load paths
'; + console.error('Failed to load paths:', e); + } +} + +async function setPathPrimary(pubkey, pathId) { + try { + await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_primary: true }) + }); + await renderPathList(pubkey); + } catch (e) { + console.error('Failed to set primary path:', e); + } +} + +async function deletePathItem(pubkey, pathId) { + try { + await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, { + method: 'DELETE' + }); + await renderPathList(pubkey); + } catch (e) { + console.error('Failed to delete path:', e); + } +} + +async function movePathItem(pubkey, paths, currentIndex, direction) { + const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + if (newIndex < 0 || newIndex >= paths.length) return; + + // Swap in the IDs array + const ids = paths.map(p => p.id); + [ids[currentIndex], ids[newIndex]] = [ids[newIndex], ids[currentIndex]]; + + try { + await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path_ids: ids }) + }); + await renderPathList(pubkey); + } catch (e) { + console.error('Failed to reorder paths:', e); + } +} + +/** + * Setup handlers for the Add Path form. + */ +function setupPathFormHandlers(pubkey) { + const addBtn = document.getElementById('dmAddPathBtn'); + const form = document.getElementById('dmAddPathForm'); + const cancelBtn = document.getElementById('dmCancelPathBtn'); + const saveBtn = document.getElementById('dmSavePathBtn'); + const pickBtn = document.getElementById('dmPickRepeaterBtn'); + const picker = document.getElementById('dmRepeaterPicker'); + const resetFloodBtn = document.getElementById('dmResetFloodBtn'); + + if (!addBtn || !form) return; + + // Remove old listeners by cloning + const newAddBtn = addBtn.cloneNode(true); + addBtn.parentNode.replaceChild(newAddBtn, addBtn); + newAddBtn.addEventListener('click', () => { + form.style.display = ''; + newAddBtn.style.display = 'none'; + document.getElementById('dmPathHexInput').value = ''; + document.getElementById('dmPathLabelInput').value = ''; + document.getElementById('dmPathUniquenessWarning').style.display = 'none'; + if (picker) picker.style.display = 'none'; + }); + + const newCancelBtn = cancelBtn.cloneNode(true); + cancelBtn.parentNode.replaceChild(newCancelBtn, cancelBtn); + newCancelBtn.addEventListener('click', () => { + form.style.display = 'none'; + newAddBtn.style.display = ''; + }); + + const newSaveBtn = saveBtn.cloneNode(true); + saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); + newSaveBtn.addEventListener('click', async () => { + const pathHex = document.getElementById('dmPathHexInput').value.replace(/[,\s→]/g, '').trim(); + const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value); + const label = document.getElementById('dmPathLabelInput').value.trim(); + + if (!pathHex) { + showNotification('Path hex is required', 'danger'); + return; + } + + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path_hex: pathHex, hash_size: hashSize, label: label }) + }); + const data = await response.json(); + if (data.success) { + form.style.display = 'none'; + newAddBtn.style.display = ''; + await renderPathList(pubkey); + showNotification('Path added', 'info'); + } else { + showNotification(data.error || 'Failed to add path', 'danger'); + } + } catch (e) { + showNotification('Failed to add path', 'danger'); + } + }); + + // Repeater picker toggle + const newPickBtn = pickBtn.cloneNode(true); + pickBtn.parentNode.replaceChild(newPickBtn, pickBtn); + newPickBtn.addEventListener('click', () => { + if (!picker) return; + if (picker.style.display === 'none') { + picker.style.display = ''; + loadRepeaterPicker(pubkey); + } else { + picker.style.display = 'none'; + } + }); + + // Repeater search filter + const searchInput = document.getElementById('dmRepeaterSearch'); + if (searchInput) { + const newSearch = searchInput.cloneNode(true); + searchInput.parentNode.replaceChild(newSearch, searchInput); + newSearch.addEventListener('input', () => { + filterRepeaterList(newSearch.value); + }); + } + + // Reset to FLOOD button + if (resetFloodBtn) { + const newResetBtn = resetFloodBtn.cloneNode(true); + resetFloodBtn.parentNode.replaceChild(newResetBtn, resetFloodBtn); + newResetBtn.addEventListener('click', async () => { + if (!confirm('Reset to FLOOD?\n\nThis will delete all configured paths and reset the device path to flood mode.')) { + return; + } + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reset_flood`, { + method: 'POST' + }); + const data = await response.json(); + if (data.success) { + await renderPathList(pubkey); + showNotification('Reset to FLOOD mode', 'info'); + } else { + showNotification(data.error || 'Reset failed', 'danger'); + } + } catch (e) { + showNotification('Reset failed', 'danger'); + } + }); + } +} + +/** + * Load repeaters for the picker dropdown. + */ +async function loadRepeaterPicker(pubkey) { + const listEl = document.getElementById('dmRepeaterList'); + if (!listEl) return; + + if (!_repeatersCache) { + try { + const response = await fetch('/api/contacts/repeaters'); + const data = await response.json(); + if (data.success) { + _repeatersCache = data.repeaters; + } + } catch (e) { + listEl.innerHTML = '
Failed to load repeaters
'; + return; + } + } + + renderRepeaterList(listEl, _repeatersCache, pubkey); +} + +function renderRepeaterList(listEl, repeaters, pubkey) { + const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value); + const hexInput = document.getElementById('dmPathHexInput'); + const searchVal = (document.getElementById('dmRepeaterSearch')?.value || '').toLowerCase(); + + const filtered = repeaters.filter(r => + r.name.toLowerCase().includes(searchVal) || + r.public_key.toLowerCase().includes(searchVal) + ); + + if (!filtered.length) { + listEl.innerHTML = '
No repeaters found
'; + return; + } + + listEl.innerHTML = ''; + filtered.forEach(rpt => { + const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase(); + // Check uniqueness: count repeaters with same prefix + const samePrefix = repeaters.filter(r => + r.public_key.substring(0, hashSize * 2).toLowerCase() === prefix.toLowerCase() + ).length; + + const item = document.createElement('div'); + item.className = 'repeater-picker-item'; + item.innerHTML = ` + ${prefix} + ${rpt.name} + ${samePrefix > 1 ? '' : ''} + `; + item.addEventListener('click', () => { + // Append hop to path hex input + const current = hexInput.value.replace(/[,\s→]/g, '').trim(); + const newVal = current + prefix.toLowerCase(); + // Format with commas for readability + const chunk = hashSize * 2; + const parts = []; + for (let i = 0; i < newVal.length; i += chunk) { + parts.push(newVal.substring(i, i + chunk)); + } + hexInput.value = parts.join(','); + + // Show uniqueness warning if applicable + checkUniquenessWarning(repeaters, hashSize); + }); + listEl.appendChild(item); + }); +} + +function filterRepeaterList(searchVal) { + if (!_repeatersCache) return; + const listEl = document.getElementById('dmRepeaterList'); + const pubkey = getCurrentContactPubkey(); + if (listEl) { + renderRepeaterList(listEl, _repeatersCache.filter(r => + r.name.toLowerCase().includes(searchVal.toLowerCase()) || + r.public_key.toLowerCase().includes(searchVal.toLowerCase()) + ), pubkey); + } +} + +function checkUniquenessWarning(repeaters, hashSize) { + const warningEl = document.getElementById('dmPathUniquenessWarning'); + if (!warningEl) return; + + const hexInput = document.getElementById('dmPathHexInput'); + const rawHex = hexInput.value.replace(/[,\s→]/g, '').trim(); + const chunk = hashSize * 2; + const hops = []; + for (let i = 0; i < rawHex.length; i += chunk) { + hops.push(rawHex.substring(i, i + chunk).toLowerCase()); + } + + const ambiguous = hops.filter(hop => { + const count = repeaters.filter(r => + r.public_key.substring(0, chunk).toLowerCase() === hop + ).length; + return count > 1; + }); + + if (ambiguous.length > 0) { + warningEl.textContent = `⚠ Ambiguous prefix(es): ${ambiguous.map(h => h.toUpperCase()).join(', ')}. Consider using a larger hash size.`; + warningEl.style.display = ''; + } else { + warningEl.style.display = 'none'; + } +} + +/** + * Load and setup the No Auto Flood toggle for current contact. + */ +async function loadNoAutoFloodToggle(pubkey) { + const toggle = document.getElementById('dmNoAutoFloodToggle'); + if (!toggle || !pubkey) return; + + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/no_auto_flood`); + const data = await response.json(); + if (data.success) { + toggle.checked = data.no_auto_flood; + } + } catch (e) { + console.debug('Failed to load no_auto_flood:', e); + } + + // Replace to avoid duplicate listeners + const newToggle = toggle.cloneNode(true); + toggle.parentNode.replaceChild(newToggle, toggle); + newToggle.id = 'dmNoAutoFloodToggle'; + + newToggle.addEventListener('change', async function () { + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/no_auto_flood`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ no_auto_flood: this.checked }) + }); + const data = await response.json(); + if (data.success) { + showNotification( + data.no_auto_flood ? 'No Flood Fallback enabled' : 'No Flood Fallback disabled', + 'info' + ); + } + } catch (e) { + console.error('Failed to update no_auto_flood:', e); + this.checked = !this.checked; + } + }); +} + +// Listen for hash size radio changes to re-render repeater list +document.addEventListener('change', (e) => { + if (e.target.name === 'pathHashSize') { + _repeatersCache = null; // Refresh to recalculate prefixes + const picker = document.getElementById('dmRepeaterPicker'); + if (picker && picker.style.display !== 'none') { + loadRepeaterPicker(getCurrentContactPubkey()); + } + // Clear path hex input when changing hash size + const hexInput = document.getElementById('dmPathHexInput'); + if (hexInput) hexInput.value = ''; + } +}); diff --git a/app/templates/dm.html b/app/templates/dm.html index 380b700..91104ac 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -101,6 +101,78 @@ background: #f8f9fa; font-weight: 600; } + + /* Path management styles */ + .path-list-item { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.5rem; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + margin-bottom: 0.25rem; + font-size: 0.8rem; + background: #fff; + } + .path-list-item.primary { + border-color: #0d6efd; + background: #f0f7ff; + } + .path-list-item .path-hex { + font-family: monospace; + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .path-list-item .path-label { + color: #6c757d; + font-size: 0.7rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .path-list-item .path-actions { + margin-left: auto; + display: flex; + gap: 0.15rem; + flex-shrink: 0; + } + .path-list-item .path-actions .btn { + padding: 0 0.25rem; + font-size: 0.7rem; + line-height: 1.2; + } + .path-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + } + .path-section-header h6 { + font-size: 0.85rem; + margin: 0; + } + .repeater-picker-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.5rem; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + font-size: 0.8rem; + } + .repeater-picker-item:hover { + background-color: #e9ecef; + } + .repeater-picker-item .badge { + font-family: monospace; + font-size: 0.7rem; + } + .path-uniqueness-warning { + color: #dc3545; + font-size: 0.75rem; + } @@ -236,11 +308,75 @@ + +