feat(dm): add multi-path management and per-contact no-flood toggle

- 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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-22 21:20:51 +01:00
parent dd81fbf0b7
commit 8cc67f77d5
6 changed files with 1091 additions and 50 deletions
+145 -1
View File
@@ -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:
+149 -46
View File
@@ -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'])
+214
View File
@@ -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/<pubkey>/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/<pubkey>/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/<pubkey>/paths/<int:path_id>', 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/<pubkey>/paths/<int:path_id>', 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/<pubkey>/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/<pubkey>/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/<pubkey>/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/<pubkey>/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."""
+13
View File
@@ -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)
+431
View File
@@ -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 = '<div class="text-muted small">Loading...</div>';
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`);
const data = await response.json();
if (!data.success || !data.paths.length) {
listEl.innerHTML = '<div class="text-muted small mb-2">No paths configured. Use + to add.</div>';
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 = `
<span class="path-hex" title="${path.path_hex}">${pathDisplay}</span>
<span class="badge bg-secondary">${hashLabel}</span>
${path.label ? `<span class="path-label" title="${path.label}">${path.label}</span>` : ''}
<span class="path-actions">
<button class="btn btn-link p-0 ${path.is_primary ? 'text-warning' : 'text-muted'}"
title="${path.is_primary ? 'Primary path' : 'Set as primary'}"
data-action="primary" data-id="${path.id}">
<i class="bi bi-star${path.is_primary ? '-fill' : ''}"></i>
</button>
${index > 0 ? `<button class="btn btn-link p-0 text-muted" title="Move up" data-action="up" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-up"></i></button>` : ''}
${index < data.paths.length - 1 ? `<button class="btn btn-link p-0 text-muted" title="Move down" data-action="down" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-down"></i></button>` : ''}
<button class="btn btn-link p-0 text-danger" title="Delete" data-action="delete" data-id="${path.id}">
<i class="bi bi-trash"></i>
</button>
</span>
`;
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 = '<div class="text-danger small">Failed to load paths</div>';
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 = '<div class="text-danger small p-2">Failed to load repeaters</div>';
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 = '<div class="text-muted small p-2">No repeaters found</div>';
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 = `
<span class="badge ${samePrefix > 1 ? 'bg-warning text-dark' : 'bg-success'}">${prefix}</span>
<span class="flex-grow-1 text-truncate">${rpt.name}</span>
${samePrefix > 1 ? '<i class="bi bi-exclamation-triangle text-warning" title="' + samePrefix + ' repeaters share this prefix"></i>' : ''}
`;
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 = '';
}
});
+139 -3
View File
@@ -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;
}
</style>
</head>
<body>
@@ -236,11 +308,75 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="dmContactInfoBody"></div>
<!-- Path management section (populated dynamically) -->
<div class="modal-body border-top pt-2 pb-1" id="dmPathSection" style="display: none;">
<div class="path-section-header">
<h6><i class="bi bi-signpost-split"></i> Paths</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="dmAddPathBtn" title="Add path">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div id="dmPathList"></div>
<!-- Add Path form (hidden by default) -->
<div id="dmAddPathForm" style="display: none;" class="border rounded p-2 mb-2">
<div class="mb-2">
<label class="form-label small mb-1">Hash Size</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
<label class="btn btn-outline-secondary" for="pathHash1">1B (max 64)</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
<label class="btn btn-outline-secondary" for="pathHash2">2B (max 32)</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
<label class="btn btn-outline-secondary" for="pathHash3">3B (max 21)</label>
</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Path (hex)</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control font-monospace" id="dmPathHexInput"
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterBtn"
title="Pick repeater">
<i class="bi bi-plus-circle"></i>
</button>
</div>
<div id="dmPathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
</div>
<!-- Repeater picker (hidden by default) -->
<div id="dmRepeaterPicker" style="display: none;" class="border rounded mb-2" style="max-height: 200px; overflow-y: auto;">
<input type="text" class="form-control form-control-sm border-0 border-bottom"
id="dmRepeaterSearch" placeholder="Search repeaters..." autocomplete="off">
<div id="dmRepeaterList" style="max-height: 180px; overflow-y: auto;"></div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Label (optional)</label>
<input type="text" class="form-control form-control-sm" id="dmPathLabelInput"
placeholder="e.g. via Mountain RPT" maxlength="50">
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="dmCancelPathBtn">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="dmSavePathBtn">Add Path</button>
</div>
</div>
<!-- Reset to FLOOD button -->
<div class="text-end mt-1">
<button type="button" class="btn btn-outline-danger btn-sm" id="dmResetFloodBtn"
title="Reset all paths and switch to FLOOD mode">
<i class="bi bi-broadcast"></i> Reset to FLOOD
</button>
</div>
</div>
<div class="modal-footer">
<div class="d-flex align-items-center justify-content-between w-100">
<div class="form-check form-switch" title="Auto Retry: resend DM if no ACK received">
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
<label class="form-check-label small" for="dmAutoRetryToggle">Auto Retry</label>
<div class="d-flex gap-3">
<div class="form-check form-switch" title="Auto Retry: resend DM if no ACK received">
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
<label class="form-check-label small" for="dmAutoRetryToggle">Auto Retry</label>
</div>
<div class="form-check form-switch" title="No Flood Fallback: never auto-reset path to FLOOD">
<input class="form-check-input" type="checkbox" id="dmNoAutoFloodToggle">
<label class="form-check-label small" for="dmNoAutoFloodToggle">No Flood</label>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>