mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-26 19:05:17 +00:00
feat(config, message_handler, repeater_manager, scheduler): implement device mode for auto-managing contacts
- Enhanced configuration options for `auto_manage_contacts` to support 'device' mode, allowing firmware to handle companion auto-addition and favourite hygiene. - Updated `MessageHandler` to track new companions based on the `auto_manage_contacts` setting, with distinct behaviors for 'false', 'device', and 'bot' modes. - Introduced scheduled jobs in `MessageScheduler` for device mode to manage firmware preferences and favourite keys with specified delays. - Modified `RepeaterManager` to skip companion auto-purge in device mode, ensuring firmware manages contact slots effectively. - Added tests to validate new behaviors and configurations, ensuring robust handling of contact management across different modes.
This commit is contained in:
@@ -178,6 +178,15 @@ startup_advert = false
|
||||
# false: Manual mode - no automatic actions, use !repeater commands to manage contacts (default)
|
||||
auto_manage_contacts = bot
|
||||
|
||||
# When auto_manage_contacts = device, the bot schedules one-shot jobs after connect:
|
||||
# - Firmware: manual per-type contact adds + overwrite oldest non-favourite on full + auto-add chat (companions) only (bitmask 0x03)
|
||||
# - Favourite hygiene: Admin_ACL and announcements ACL pubkeys are favourited on the radio; other favourites are cleared so eviction can recycle slots
|
||||
# Delays (seconds) before each step so the radio is not flooded at boot (defaults shown)
|
||||
#device_mode_firmware_delay_seconds = 30
|
||||
#device_mode_favourite_pass1_delay_seconds = 90
|
||||
#device_mode_favourite_pass2_delay_seconds = 180
|
||||
#contact_flag_update_spacing_ms = 200
|
||||
|
||||
# Database path for main bot database
|
||||
# Default: meshcore_bot.db
|
||||
db_path = meshcore_bot.db
|
||||
@@ -313,6 +322,7 @@ admin_commands = repeater,webviewer,reload,channelpause
|
||||
# Enable companion contact purging
|
||||
# true: Purge inactive companions when contact list is full
|
||||
# false: Never purge companions (default: false for safety)
|
||||
# When [Bot] auto_manage_contacts=device, companion auto-purge from the radio is always skipped (firmware slot policy).
|
||||
companion_purge_enabled = false
|
||||
|
||||
# Days since last DM to consider companion inactive
|
||||
|
||||
@@ -823,9 +823,9 @@ repeater stats
|
||||
- NEW_CONTACT events are automatically monitored
|
||||
- Repeaters are automatically cataloged when discovered
|
||||
- Contact list capacity is monitored in real-time
|
||||
- `auto_manage_contacts = device`: Device handles auto-addition, bot manages capacity
|
||||
- `auto_manage_contacts = bot`: Bot automatically adds companion contacts and manages capacity
|
||||
- `auto_manage_contacts = false`: Manual mode - use `!repeater` commands to manage contacts
|
||||
- `auto_manage_contacts = device`: Firmware auto-adds **chat (companion)** peers only, with **overwrite oldest non-favourite** when the contact table is full; the bot schedules delayed jobs to set that firmware policy and to **favourite** keys in `Admin_ACL` plus the effective announcements ACL (same rules as the announcements command), then clear **favourite** on other contacts. The bot still runs capacity management on NEW_CONTACT (near-limit `manage_contact_list`) and does **not** call `add_contact` for new companions itself. **Contact limit** for logging and capacity is taken from the radio’s `max_contacts` and, if the live table is larger (under-reported max), raised to match the mesh so counts are not shown as over-capacity. **Companion auto-purge** never runs on the radio in this mode. Count-based **repeater** auto-purge only runs if the table grows **strictly above** that synced limit (normally off while the firmware manages slots).
|
||||
- `auto_manage_contacts = bot`: Bot adds new companions via `add_contact` (full NEW_CONTACT payload), runs **manage-before-add** when the list is near limit, and **retries once** after `manage_contact_list` if the radio returns `TABLE_FULL`.
|
||||
- `auto_manage_contacts = false`: Manual mode - NEW_CONTACT companions are tracked in the database only; use `!repeater` commands to manage the device list.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+76
-15
@@ -3273,28 +3273,89 @@ class MessageHandler:
|
||||
self.logger.info(f"✅ Repeater {contact_name} tracked in database - not added to device contacts")
|
||||
return
|
||||
else:
|
||||
# COMPANION: Track in database AND add to device contact list
|
||||
self.logger.info(f"👤 New companion discovered: {contact_name} - will be added to device contacts")
|
||||
# COMPANION: track in DB; device add behaviour depends on auto_manage_contacts
|
||||
auto_manage_setting = self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower()
|
||||
self.logger.info(
|
||||
"👤 New companion discovered: %s — auto_manage_contacts=%s",
|
||||
contact_name,
|
||||
auto_manage_setting,
|
||||
)
|
||||
|
||||
# Track companion in complete database with signal info
|
||||
await self.bot.repeater_manager.track_contact_advertisement(contact_data, signal_info, packet_hash=packet_hash)
|
||||
await self.bot.repeater_manager.track_contact_advertisement(
|
||||
contact_data, signal_info, packet_hash=packet_hash
|
||||
)
|
||||
|
||||
# Add companion to device contact list
|
||||
try:
|
||||
self._ensure_contact_meshcore_path_encoding(contact_data)
|
||||
result = await self.bot.meshcore.commands.add_contact(contact_data)
|
||||
if hasattr(result, 'type') and result.type.name == 'OK':
|
||||
self.logger.info(f"✅ Companion {contact_name} added to device contacts")
|
||||
if auto_manage_setting == 'false':
|
||||
self.logger.info(
|
||||
"Manual mode — companion %s tracked in database only (not added to device)",
|
||||
contact_name,
|
||||
)
|
||||
elif auto_manage_setting == 'device':
|
||||
self.logger.info(
|
||||
"Device mode — companion %s tracked; firmware handles addition; bot may manage capacity",
|
||||
contact_name,
|
||||
)
|
||||
status = await self.bot.repeater_manager.get_contact_list_status()
|
||||
if status and status.get('is_near_limit', False):
|
||||
self.logger.warning(
|
||||
"Contact list near limit (%.1f%%) — managing capacity",
|
||||
status['usage_percentage'],
|
||||
)
|
||||
await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True)
|
||||
else:
|
||||
self.logger.warning(f"❌ Failed to add companion {contact_name} to device: {result}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"❌ Error adding companion {contact_name} to device: {e}")
|
||||
self.logger.info(
|
||||
"New companion %s — contact list has adequate space",
|
||||
contact_name,
|
||||
)
|
||||
elif auto_manage_setting == 'bot':
|
||||
self.logger.info(
|
||||
"Bot mode — adding companion %s to device with capacity management",
|
||||
contact_name,
|
||||
)
|
||||
try:
|
||||
self._ensure_contact_meshcore_path_encoding(contact_data)
|
||||
ok = await self.bot.repeater_manager.add_companion_from_contact_data(
|
||||
contact_data, contact_name, public_key
|
||||
)
|
||||
if not ok:
|
||||
self.logger.warning(
|
||||
"Failed to add companion contact %s to device after managed add/retry",
|
||||
contact_name,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error("Error adding companion %s to device: %s", contact_name, e)
|
||||
|
||||
status = await self.bot.repeater_manager.get_contact_list_status()
|
||||
if status and status.get('is_near_limit', False):
|
||||
self.logger.warning(
|
||||
"Contact list near limit (%.1f%%) — managing capacity after add",
|
||||
status['usage_percentage'],
|
||||
)
|
||||
await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Companion %s — contact list has adequate space after add attempt",
|
||||
contact_name,
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Unknown auto_manage_contacts value %r — treating as manual for %s",
|
||||
auto_manage_setting,
|
||||
contact_name,
|
||||
)
|
||||
|
||||
# Check if auto-purge is needed
|
||||
await self.bot.repeater_manager.check_and_auto_purge()
|
||||
|
||||
self.bot.repeater_manager.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
(
|
||||
'new_contact_discovered',
|
||||
f'New contact discovered: {contact_name} (key: {public_key[:16]}...)',
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Fallback: Track in database for unknown contact types
|
||||
# Fallback: Track in database for unknown contact types (no repeater_manager)
|
||||
if hasattr(self.bot, 'repeater_manager'):
|
||||
await self.bot.repeater_manager.track_contact_advertisement(contact_data, packet_hash=packet_hash)
|
||||
await self.bot.repeater_manager.check_and_auto_purge()
|
||||
|
||||
+325
-21
@@ -12,10 +12,47 @@ from typing import Any, Optional
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from .security_utils import sanitize_name
|
||||
from .security_utils import sanitize_name, validate_pubkey_format
|
||||
from .utils import rate_limited_nominatim_reverse_sync
|
||||
|
||||
|
||||
def collect_protected_pubkeys_for_device_mode(config: Any, logger: Any) -> set[str]:
|
||||
"""Public keys that must stay favourited on the radio (admin + announcements ACL semantics).
|
||||
|
||||
Matches announcements_command._load_announcements_acl: explicit announcements_acl keys plus
|
||||
all Admin_ACL admin_pubkeys (admins always included).
|
||||
"""
|
||||
keys: list[str] = []
|
||||
try:
|
||||
if config.has_section('Announcements_Command'):
|
||||
ann = config.get('Announcements_Command', 'announcements_acl', fallback='')
|
||||
if ann and ann.strip():
|
||||
for key in ann.split(','):
|
||||
key = key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if validate_pubkey_format(key, expected_length=64):
|
||||
keys.append(key.lower())
|
||||
else:
|
||||
logger.warning('Invalid pubkey in announcements_acl: %s...', key[:16])
|
||||
if config.has_section('Admin_ACL'):
|
||||
admin_pubkeys = config.get('Admin_ACL', 'admin_pubkeys', fallback='')
|
||||
if admin_pubkeys and admin_pubkeys.strip():
|
||||
for key in admin_pubkeys.split(','):
|
||||
key = key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if validate_pubkey_format(key, expected_length=64):
|
||||
nk = key.lower()
|
||||
if nk not in keys:
|
||||
keys.append(nk)
|
||||
else:
|
||||
logger.warning('Invalid pubkey in admin_pubkeys: %s...', key[:16])
|
||||
except Exception as e:
|
||||
logger.debug('collect_protected_pubkeys_for_device_mode: %s', e)
|
||||
return set(keys)
|
||||
|
||||
|
||||
class RepeaterManager:
|
||||
"""Manages repeater contacts database and purging operations"""
|
||||
|
||||
@@ -35,6 +72,7 @@ class RepeaterManager:
|
||||
self.auto_purge_threshold = 280 # Start purging when 280+ contacts
|
||||
# Respect auto_manage_contacts: manual mode (false) = no auto-purge; device/bot = auto-purge on
|
||||
auto_manage = bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower()
|
||||
self._auto_manage_contacts = auto_manage
|
||||
self.auto_purge_enabled = (auto_manage != 'false')
|
||||
|
||||
# Initialize companion purge settings
|
||||
@@ -648,11 +686,26 @@ class RepeaterManager:
|
||||
if not self.auto_purge_enabled:
|
||||
return False
|
||||
|
||||
if not getattr(self.bot, 'meshcore', None) or not hasattr(self.bot.meshcore, 'contacts'):
|
||||
return False
|
||||
|
||||
await self._update_contact_limit_from_device()
|
||||
|
||||
# Get current contact count
|
||||
current_count = len(self.bot.meshcore.contacts)
|
||||
|
||||
if current_count >= self.auto_purge_threshold:
|
||||
self.logger.info(f"🔄 Auto-purge triggered: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.logger.info(
|
||||
"Repeater-only auto-purge check: %s/%s contacts (threshold %s)",
|
||||
current_count,
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
f"🔄 Auto-purge triggered: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})"
|
||||
)
|
||||
|
||||
# Calculate how many to purge
|
||||
target_count = self.auto_purge_threshold - 20 # Leave some buffer
|
||||
@@ -663,19 +716,46 @@ class RepeaterManager:
|
||||
repeater_success = await self._auto_purge_repeaters(purge_count)
|
||||
remaining_count = len(self.bot.meshcore.contacts)
|
||||
|
||||
# If still above threshold and companion purging is enabled, purge companions
|
||||
if remaining_count >= self.auto_purge_threshold and self.companion_purge_enabled:
|
||||
companion_success = False
|
||||
# If still above threshold and companion purging is enabled, purge companions.
|
||||
# Device mode: never auto-purge companions from the radio — firmware overwrite + favourite
|
||||
# hygiene own the contact table; repeater purge above may still free slots.
|
||||
if (
|
||||
remaining_count >= self.auto_purge_threshold
|
||||
and self.companion_purge_enabled
|
||||
and self._auto_manage_contacts != 'device'
|
||||
):
|
||||
remaining_purge_count = remaining_count - target_count
|
||||
self.logger.info(f"Still above threshold after repeater purge, purging {remaining_purge_count} companions...")
|
||||
companion_success = await self._auto_purge_companions(remaining_purge_count)
|
||||
elif (
|
||||
remaining_count >= self.auto_purge_threshold
|
||||
and self.companion_purge_enabled
|
||||
and self._auto_manage_contacts == 'device'
|
||||
):
|
||||
self.logger.info(
|
||||
"Still above contact threshold after repeater purge; skipping companion auto-purge "
|
||||
"(auto_manage_contacts=device — firmware handles slot policy)"
|
||||
)
|
||||
|
||||
if repeater_success or companion_success:
|
||||
final_count = len(self.bot.meshcore.contacts)
|
||||
self.logger.info(f"✅ Auto-purge completed, now at {final_count}/{self.contact_limit} contacts")
|
||||
return True
|
||||
if repeater_success or companion_success:
|
||||
final_count = len(self.bot.meshcore.contacts)
|
||||
self.logger.info(f"✅ Auto-purge completed, now at {final_count}/{self.contact_limit} contacts")
|
||||
return True
|
||||
elif repeater_success:
|
||||
self.logger.info(f"✅ Auto-purged {purge_count} repeaters, now at {remaining_count}/{self.contact_limit} contacts")
|
||||
return True
|
||||
elif (
|
||||
self._auto_manage_contacts == 'device'
|
||||
and remaining_count >= self.auto_purge_threshold
|
||||
):
|
||||
# Not an error: we do not bulk-remove companions on the radio in device mode.
|
||||
self.logger.info(
|
||||
"Device mode: %s contacts still at/above threshold; no repeater slots freed. "
|
||||
"Companion list changes are left to firmware (overwrite non-favourite / favourites).",
|
||||
remaining_count,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"❌ Auto-purge failed to remove {purge_count} contacts")
|
||||
return False
|
||||
@@ -695,7 +775,13 @@ class RepeaterManager:
|
||||
repeaters_to_purge = await self._get_repeaters_for_purging(count)
|
||||
|
||||
if not repeaters_to_purge:
|
||||
self.logger.warning("No repeaters available for auto-purge")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.logger.debug(
|
||||
"No repeaters on device for repeater-only auto-purge check (%s contacts)",
|
||||
len(self.bot.meshcore.contacts),
|
||||
)
|
||||
else:
|
||||
self.logger.warning("No repeaters available for auto-purge")
|
||||
# Log some debugging info
|
||||
total_contacts = len(self.bot.meshcore.contacts)
|
||||
repeater_count = sum(1 for contact_data in list(self.bot.meshcore.contacts.values()) if self._is_repeater_device(contact_data))
|
||||
@@ -2680,6 +2766,163 @@ class RepeaterManager:
|
||||
self.logger.error(f"Error in aggressive contact cleanup: {e}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _is_meshcore_table_full(result: Any) -> bool:
|
||||
if result is None or not hasattr(result, 'type'):
|
||||
return False
|
||||
if result.type != EventType.ERROR:
|
||||
return False
|
||||
payload = getattr(result, 'payload', None) or {}
|
||||
if payload.get('error_code') == 3:
|
||||
return True
|
||||
code_str = str(payload.get('code_string', '') or '')
|
||||
return 'TABLE_FULL' in code_str.upper()
|
||||
|
||||
async def add_companion_from_contact_data(
|
||||
self,
|
||||
contact_data: dict[str, Any],
|
||||
contact_name: str,
|
||||
public_key: str,
|
||||
) -> bool:
|
||||
"""Add companion using full contact_data (paths). Pre-manages when near limit; retries once on TABLE_FULL."""
|
||||
try:
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
self.logger.error('No meshcore commands — cannot add companion %s', contact_name)
|
||||
return False
|
||||
|
||||
status = await self.get_contact_list_status()
|
||||
if status and status.get('is_near_limit'):
|
||||
self.logger.warning(
|
||||
'Contact list near limit (%.1f%%) before add — managing capacity for %s',
|
||||
status.get('usage_percentage', 0.0),
|
||||
contact_name,
|
||||
)
|
||||
await self.manage_contact_list(auto_cleanup=True)
|
||||
|
||||
result = await self.bot.meshcore.commands.add_contact(contact_data)
|
||||
if hasattr(result, 'type') and result.type == EventType.OK:
|
||||
self.logger.info("Companion %s added to device contacts", contact_name)
|
||||
return True
|
||||
|
||||
if self._is_meshcore_table_full(result):
|
||||
self.logger.warning(
|
||||
'TABLE_FULL adding companion %s — running manage_contact_list and retrying once',
|
||||
contact_name,
|
||||
)
|
||||
await self.manage_contact_list(auto_cleanup=True)
|
||||
result = await self.bot.meshcore.commands.add_contact(contact_data)
|
||||
if hasattr(result, 'type') and result.type == EventType.OK:
|
||||
self.logger.info('Companion %s added after retry', contact_name)
|
||||
return True
|
||||
|
||||
self.logger.warning('Failed to add companion %s to device: %s', contact_name, result)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error('Error adding companion %s: %s', contact_name, e)
|
||||
return False
|
||||
|
||||
async def apply_device_mode_firmware_preferences(self) -> bool:
|
||||
"""Set companion-radio firmware: manual per-type adds + overwrite oldest non-favourite + chat-only (0x03)."""
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
return False
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
self.logger.info('Skipping firmware autoadd setup — auto_manage_contacts is not device')
|
||||
return False
|
||||
try:
|
||||
cmds = self.bot.meshcore.commands
|
||||
r_manual = await cmds.set_manual_add_contacts(True)
|
||||
if hasattr(r_manual, 'type') and r_manual.type != EventType.OK:
|
||||
self.logger.warning('set_manual_add_contacts(True) returned %s', r_manual)
|
||||
r_auto = await cmds.set_autoadd_config(0x03)
|
||||
if hasattr(r_auto, 'type') and r_auto.type != EventType.OK:
|
||||
self.logger.warning('set_autoadd_config(0x03) returned %s', r_auto)
|
||||
return False
|
||||
self.logger.info('Device mode: firmware autoadd_config=0x03 (overwrite oldest + chat only), manual add on')
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error('apply_device_mode_firmware_preferences failed: %s', e)
|
||||
return False
|
||||
|
||||
async def sync_device_mode_favourites_pass1(self) -> None:
|
||||
"""Favourite all on-device contacts whose pubkey is in the protected set (admin + announcements ACL)."""
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
return
|
||||
protected = collect_protected_pubkeys_for_device_mode(self.bot.config, self.logger)
|
||||
if not protected:
|
||||
self.logger.debug('sync_device_mode_favourites_pass1: no protected pubkeys configured')
|
||||
spacing_ms = max(0, self.bot.config.getint('Bot', 'contact_flag_update_spacing_ms', fallback=200))
|
||||
spacing = spacing_ms / 1000.0
|
||||
try:
|
||||
await self.bot.meshcore.commands.get_contacts()
|
||||
except Exception as e:
|
||||
self.logger.debug('get_contacts before favourite pass1: %s', e)
|
||||
contacts = getattr(self.bot.meshcore, 'contacts', None) or {}
|
||||
for pub_key, c in list(contacts.items()):
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
pk = (pub_key or '').lower()
|
||||
if pk not in protected:
|
||||
continue
|
||||
try:
|
||||
flags = int(c.get('flags', 0))
|
||||
except (TypeError, ValueError):
|
||||
flags = 0
|
||||
if flags & 1:
|
||||
continue
|
||||
contact_copy = dict(c)
|
||||
new_flags = flags | 1
|
||||
try:
|
||||
res = await self.bot.meshcore.commands.change_contact_flags(contact_copy, new_flags)
|
||||
if hasattr(res, 'type') and res.type == EventType.OK:
|
||||
self.logger.debug('Favourited protected contact %s', pk[:16])
|
||||
else:
|
||||
self.logger.warning('change_contact_flags (favourite on) failed for %s: %s', pk[:16], res)
|
||||
except Exception as e:
|
||||
self.logger.warning('change_contact_flags error for %s: %s', pk[:16], e)
|
||||
if spacing > 0:
|
||||
await asyncio.sleep(spacing)
|
||||
|
||||
async def sync_device_mode_favourites_pass2(self) -> None:
|
||||
"""Clear favourite bit for contacts not in the protected set."""
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
return
|
||||
protected = collect_protected_pubkeys_for_device_mode(self.bot.config, self.logger)
|
||||
spacing_ms = max(0, self.bot.config.getint('Bot', 'contact_flag_update_spacing_ms', fallback=200))
|
||||
spacing = spacing_ms / 1000.0
|
||||
try:
|
||||
await self.bot.meshcore.commands.get_contacts()
|
||||
except Exception as e:
|
||||
self.logger.debug('get_contacts before favourite pass2: %s', e)
|
||||
contacts = getattr(self.bot.meshcore, 'contacts', None) or {}
|
||||
for pub_key, c in list(contacts.items()):
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
pk = (pub_key or '').lower()
|
||||
if pk in protected:
|
||||
continue
|
||||
try:
|
||||
flags = int(c.get('flags', 0))
|
||||
except (TypeError, ValueError):
|
||||
flags = 0
|
||||
if not (flags & 1):
|
||||
continue
|
||||
contact_copy = dict(c)
|
||||
new_flags = flags & ~1
|
||||
try:
|
||||
res = await self.bot.meshcore.commands.change_contact_flags(contact_copy, new_flags)
|
||||
if hasattr(res, 'type') and res.type == EventType.OK:
|
||||
self.logger.debug('Cleared favourite for non-protected contact %s', pk[:16])
|
||||
else:
|
||||
self.logger.warning('change_contact_flags (favourite off) failed for %s: %s', pk[:16], res)
|
||||
except Exception as e:
|
||||
self.logger.warning('change_contact_flags error for %s: %s', pk[:16], e)
|
||||
if spacing > 0:
|
||||
await asyncio.sleep(spacing)
|
||||
|
||||
async def add_discovered_contact(self, contact_name: str, public_key: Optional[str] = None, reason: str = "Manual addition") -> bool:
|
||||
"""Add a discovered contact to the contact list using multiple methods"""
|
||||
try:
|
||||
@@ -3167,11 +3410,27 @@ class RepeaterManager:
|
||||
if not self.auto_purge_enabled:
|
||||
return
|
||||
|
||||
if not getattr(self.bot, 'meshcore', None) or not hasattr(self.bot.meshcore, 'contacts'):
|
||||
return
|
||||
|
||||
await self._update_contact_limit_from_device()
|
||||
|
||||
current_count = len(self.bot.meshcore.contacts)
|
||||
|
||||
# Log current status
|
||||
if current_count >= self.auto_purge_threshold:
|
||||
self.logger.warning(f"⚠️ Contact limit monitoring: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.logger.info(
|
||||
"📊 Contact list high (%s/%s, threshold %s); device mode — running repeater-only auto-purge check "
|
||||
"(companions not bulk-removed by bot)",
|
||||
current_count,
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"⚠️ Contact limit monitoring: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})"
|
||||
)
|
||||
|
||||
# Trigger auto-purge
|
||||
await self.check_and_auto_purge()
|
||||
@@ -3266,31 +3525,76 @@ class RepeaterManager:
|
||||
self.logger.debug(f"Background geocoding error: {e}")
|
||||
|
||||
async def _update_contact_limit_from_device(self):
|
||||
"""Update contact limit from device using proper MeshCore API"""
|
||||
"""Update contact limit from device using proper MeshCore API.
|
||||
|
||||
In ``auto_manage_contacts=device`` mode, the effective limit is at least the
|
||||
number of contacts currently on the radio (firmware can report a lower
|
||||
``max_contacts`` than the live table). Count-based auto-purge uses
|
||||
``threshold = contact_limit + 1`` so routine companion-heavy meshes do not
|
||||
loop through repeater-only purge when the firmware manages the table.
|
||||
"""
|
||||
current_mesh = 0
|
||||
try:
|
||||
if self.bot.meshcore and hasattr(self.bot.meshcore, 'contacts'):
|
||||
current_mesh = len(self.bot.meshcore.contacts)
|
||||
except Exception:
|
||||
current_mesh = 0
|
||||
|
||||
try:
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.contact_limit = max(self.contact_limit, current_mesh)
|
||||
self.auto_purge_threshold = self.contact_limit + 1
|
||||
self.logger.debug(
|
||||
"Device mode contact limit from mesh only: %s (threshold %s)",
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
return False
|
||||
|
||||
# Use the correct MeshCore API to get device info
|
||||
device_info = await self.bot.meshcore.commands.send_device_query()
|
||||
|
||||
# Check if the query was successful
|
||||
if hasattr(device_info, 'type') and device_info.type.name == 'DEVICE_INFO':
|
||||
max_contacts = device_info.payload.get("max_contacts")
|
||||
if hasattr(device_info, 'type') and device_info.type == EventType.DEVICE_INFO:
|
||||
raw_max = device_info.payload.get('max_contacts')
|
||||
try:
|
||||
max_contacts = int(raw_max) if raw_max is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
max_contacts = 0
|
||||
|
||||
if max_contacts and max_contacts > 100:
|
||||
if max_contacts > 100:
|
||||
self.contact_limit = max_contacts
|
||||
# Update threshold to be 20 contacts below the limit
|
||||
self.auto_purge_threshold = max(200, max_contacts - 20)
|
||||
self.logger.debug(f"Updated contact limit from device query: {self.contact_limit} (threshold: {self.auto_purge_threshold})")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.contact_limit = max(self.contact_limit, current_mesh)
|
||||
self.auto_purge_threshold = self.contact_limit + 1
|
||||
else:
|
||||
# Update threshold to be 20 contacts below the limit
|
||||
self.auto_purge_threshold = max(200, self.contact_limit - 20)
|
||||
self.logger.debug(
|
||||
"Updated contact limit from device query: %s (threshold: %s)",
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.logger.debug(f"Device returned invalid max_contacts: {max_contacts}")
|
||||
|
||||
self.logger.debug(f"Device returned invalid max_contacts: {raw_max}")
|
||||
else:
|
||||
self.logger.debug(f"Device query failed: {device_info}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not update contact limit from device: {e}")
|
||||
|
||||
# Keep default values if device query failed
|
||||
self.logger.debug(f"Using default contact limit: {self.contact_limit}")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.contact_limit = max(self.contact_limit, current_mesh)
|
||||
self.auto_purge_threshold = self.contact_limit + 1
|
||||
self.logger.debug(
|
||||
"Device mode contact limit after failed query: %s (threshold %s)",
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
else:
|
||||
self.logger.debug(f"Using default contact limit: {self.contact_limit}")
|
||||
return False
|
||||
|
||||
async def get_auto_purge_status(self) -> dict:
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any, Optional
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
|
||||
from .maintenance import MaintenanceRunner
|
||||
from .security_utils import validate_external_url
|
||||
@@ -101,6 +102,8 @@ class MessageScheduler:
|
||||
self._apscheduler.start()
|
||||
self.logger.info(f"APScheduler started with {len(self.scheduled_messages)} scheduled message(s)")
|
||||
|
||||
self._setup_device_mode_scheduler_jobs()
|
||||
|
||||
# Setup interval-based advertising
|
||||
self.setup_interval_advertising()
|
||||
|
||||
@@ -118,6 +121,84 @@ class MessageScheduler:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error setting up interval advertising: {e}")
|
||||
|
||||
def _setup_device_mode_scheduler_jobs(self) -> None:
|
||||
"""One-shot jobs for auto_manage_contacts=device: firmware autoadd + favourite hygiene."""
|
||||
if self._apscheduler is None:
|
||||
return
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
try:
|
||||
delay_fw = max(0, self.bot.config.getint('Bot', 'device_mode_firmware_delay_seconds', fallback=30))
|
||||
delay_p1 = max(0, self.bot.config.getint('Bot', 'device_mode_favourite_pass1_delay_seconds', fallback=90))
|
||||
delay_p2 = max(0, self.bot.config.getint('Bot', 'device_mode_favourite_pass2_delay_seconds', fallback=180))
|
||||
base = self.get_current_time()
|
||||
self._apscheduler.add_job(
|
||||
self._device_mode_firmware_job_sync,
|
||||
trigger=DateTrigger(run_date=base + datetime.timedelta(seconds=delay_fw)),
|
||||
id='device_mode_firmware_autoadd',
|
||||
replace_existing=True,
|
||||
)
|
||||
self._apscheduler.add_job(
|
||||
self._device_mode_favourite_pass1_job_sync,
|
||||
trigger=DateTrigger(run_date=base + datetime.timedelta(seconds=delay_p1)),
|
||||
id='device_mode_favourite_pass1',
|
||||
replace_existing=True,
|
||||
)
|
||||
self._apscheduler.add_job(
|
||||
self._device_mode_favourite_pass2_job_sync,
|
||||
trigger=DateTrigger(run_date=base + datetime.timedelta(seconds=delay_p2)),
|
||||
id='device_mode_favourite_pass2',
|
||||
replace_existing=True,
|
||||
)
|
||||
self.logger.info(
|
||||
'Scheduled device-mode jobs: firmware +%ss, favourite pass1 +%ss, pass2 +%ss',
|
||||
delay_fw,
|
||||
delay_p1,
|
||||
delay_p2,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning('Could not schedule device-mode contact jobs: %s', e)
|
||||
|
||||
def _run_async_on_main_loop(self, coro: Any, timeout: float = 300.0) -> None:
|
||||
"""Run async coroutine on bot main loop from APScheduler thread (same pattern as send_scheduled_message)."""
|
||||
import asyncio
|
||||
|
||||
if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running():
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self.bot.main_event_loop)
|
||||
try:
|
||||
future.result(timeout=timeout)
|
||||
except RuntimeError as e:
|
||||
self.logger.warning('Event loop gone during device-mode job: %s', e)
|
||||
except Exception as e:
|
||||
self.logger.error('Device-mode scheduled job failed: %s', e)
|
||||
else:
|
||||
self.logger.warning('No running main_event_loop — skipping device-mode scheduled job')
|
||||
|
||||
async def _device_mode_firmware_coro(self) -> None:
|
||||
await self.bot.repeater_manager.apply_device_mode_firmware_preferences()
|
||||
|
||||
async def _device_mode_favourite_pass1_coro(self) -> None:
|
||||
await self.bot.repeater_manager.sync_device_mode_favourites_pass1()
|
||||
|
||||
async def _device_mode_favourite_pass2_coro(self) -> None:
|
||||
await self.bot.repeater_manager.sync_device_mode_favourites_pass2()
|
||||
|
||||
def _device_mode_firmware_job_sync(self) -> None:
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
self.logger.debug('Skipping device_mode_firmware job — not device mode')
|
||||
return
|
||||
self._run_async_on_main_loop(self._device_mode_firmware_coro(), timeout=120.0)
|
||||
|
||||
def _device_mode_favourite_pass1_job_sync(self) -> None:
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
self._run_async_on_main_loop(self._device_mode_favourite_pass1_coro(), timeout=600.0)
|
||||
|
||||
def _device_mode_favourite_pass2_job_sync(self) -> None:
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
self._run_async_on_main_loop(self._device_mode_favourite_pass2_coro(), timeout=600.0)
|
||||
|
||||
def _is_valid_time_format(self, time_str: str) -> bool:
|
||||
"""Validate time format (HHMM)"""
|
||||
try:
|
||||
|
||||
@@ -1817,3 +1817,107 @@ class TestRespondToMentions:
|
||||
await mention_handler.process_message(msg)
|
||||
assert msg.content == "ping"
|
||||
mention_bot.command_manager.execute_commands.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_new_contact — auto_manage_contacts (companion path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _NewContactEvent:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self.payload = payload
|
||||
|
||||
|
||||
def _companion_contact_payload() -> dict:
|
||||
pk = "ab" * 32
|
||||
return {
|
||||
"public_key": pk,
|
||||
"adv_name": "Alice",
|
||||
"name": "Alice",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def new_contact_env(mock_logger):
|
||||
"""Bot + MessageHandler with mocked repeater_manager and meshcore for NEW_CONTACT tests."""
|
||||
bot = Mock()
|
||||
bot.logger = mock_logger
|
||||
bot.config = configparser.ConfigParser()
|
||||
bot.config.add_section("Bot")
|
||||
bot.config.set("Bot", "enabled", "true")
|
||||
bot.config.set("Bot", "rf_data_timeout", "15.0")
|
||||
bot.config.set("Bot", "message_correlation_timeout", "10.0")
|
||||
bot.config.set("Bot", "enable_enhanced_correlation", "true")
|
||||
bot.config.add_section("Channels")
|
||||
bot.config.set("Channels", "respond_to_dms", "true")
|
||||
bot.connection_time = None
|
||||
bot.prefix_hex_chars = 8
|
||||
bot.command_manager = Mock()
|
||||
bot.command_manager.monitor_channels = ["general"]
|
||||
bot.command_manager.is_user_banned = Mock(return_value=False)
|
||||
bot.command_manager.commands = {}
|
||||
|
||||
handler = MessageHandler(bot)
|
||||
bot.message_handler = handler
|
||||
|
||||
rm = Mock()
|
||||
rm.track_contact_advertisement = AsyncMock()
|
||||
rm.check_and_auto_purge = AsyncMock()
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={
|
||||
"is_near_limit": False,
|
||||
"usage_percentage": 10.0,
|
||||
"current_contacts": 5,
|
||||
"estimated_limit": 300,
|
||||
}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock(return_value={"success": True})
|
||||
rm.add_companion_from_contact_data = AsyncMock(return_value=True)
|
||||
rm.db_manager = Mock()
|
||||
rm.db_manager.execute_update = Mock()
|
||||
rm._is_repeater_device = Mock(return_value=False)
|
||||
bot.repeater_manager = rm
|
||||
|
||||
mesh = Mock()
|
||||
mesh.commands = Mock()
|
||||
mesh.commands.add_contact = AsyncMock()
|
||||
bot.meshcore = mesh
|
||||
|
||||
return bot, handler, rm, mesh
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHandleNewContactAutoManage:
|
||||
async def test_manual_mode_no_device_add(self, new_contact_env):
|
||||
bot, handler, rm, mesh = new_contact_env
|
||||
bot.config.set("Bot", "auto_manage_contacts", "false")
|
||||
ev = _NewContactEvent(_companion_contact_payload())
|
||||
await handler.handle_new_contact(ev, None)
|
||||
rm.track_contact_advertisement.assert_awaited_once()
|
||||
mesh.commands.add_contact.assert_not_called()
|
||||
rm.add_companion_from_contact_data.assert_not_called()
|
||||
rm.db_manager.execute_update.assert_called()
|
||||
|
||||
async def test_device_mode_no_bot_add_contact(self, new_contact_env):
|
||||
bot, handler, rm, mesh = new_contact_env
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
ev = _NewContactEvent(_companion_contact_payload())
|
||||
await handler.handle_new_contact(ev, None)
|
||||
rm.track_contact_advertisement.assert_awaited_once()
|
||||
mesh.commands.add_contact.assert_not_called()
|
||||
rm.add_companion_from_contact_data.assert_not_called()
|
||||
rm.get_contact_list_status.assert_awaited()
|
||||
|
||||
async def test_bot_mode_uses_add_companion_from_contact_data(self, new_contact_env):
|
||||
bot, handler, rm, mesh = new_contact_env
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
ev = _NewContactEvent(_companion_contact_payload())
|
||||
await handler.handle_new_contact(ev, None)
|
||||
rm.add_companion_from_contact_data.assert_awaited_once()
|
||||
mesh.commands.add_contact.assert_not_called()
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
from modules.message_handler import MessageHandler
|
||||
from modules.repeater_manager import RepeaterManager
|
||||
|
||||
|
||||
def _make_config_get():
|
||||
@@ -31,11 +33,20 @@ def message_handler():
|
||||
|
||||
@pytest.fixture
|
||||
def companion_new_contact_setup():
|
||||
"""Bot + MessageHandler wired for companion NEW_CONTACT → add_contact."""
|
||||
"""Bot + MessageHandler wired for companion NEW_CONTACT → add_contact (bot auto-manage)."""
|
||||
bot = MagicMock()
|
||||
bot.logger = MagicMock()
|
||||
|
||||
def _config_get(section, key, **kw):
|
||||
defaults = {
|
||||
("Bot", "rf_data_timeout"): "15.0",
|
||||
("Bot", "message_correlation_timeout"): "10.0",
|
||||
("Bot", "auto_manage_contacts"): "bot",
|
||||
}
|
||||
return defaults.get((section, key), kw.get("fallback", ""))
|
||||
|
||||
bot.config = MagicMock()
|
||||
bot.config.get = MagicMock(side_effect=_make_config_get())
|
||||
bot.config.get = MagicMock(side_effect=_config_get)
|
||||
bot.config.getboolean = MagicMock(return_value=True)
|
||||
bot.prefix_hex_chars = 8
|
||||
|
||||
@@ -43,14 +54,26 @@ def companion_new_contact_setup():
|
||||
bot.message_handler = mh
|
||||
|
||||
rm = MagicMock()
|
||||
rm.bot = bot
|
||||
rm.logger = bot.logger
|
||||
rm._is_repeater_device = MagicMock(return_value=False)
|
||||
rm.track_contact_advertisement = AsyncMock()
|
||||
rm.check_and_auto_purge = AsyncMock()
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={"is_near_limit": False, "usage_percentage": 0.0}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock()
|
||||
rm.db_manager = MagicMock()
|
||||
rm.db_manager.execute_update = MagicMock()
|
||||
|
||||
async def _add_companion(contact_data, contact_name, public_key):
|
||||
return await RepeaterManager.add_companion_from_contact_data(rm, contact_data, contact_name, public_key)
|
||||
|
||||
rm.add_companion_from_contact_data = AsyncMock(side_effect=_add_companion)
|
||||
bot.repeater_manager = rm
|
||||
|
||||
ok = MagicMock()
|
||||
ok.type = MagicMock()
|
||||
ok.type.name = "OK"
|
||||
ok.type = EventType.OK
|
||||
bot.meshcore = MagicMock()
|
||||
bot.meshcore.commands = MagicMock()
|
||||
bot.meshcore.commands.add_contact = AsyncMock(return_value=ok)
|
||||
|
||||
@@ -9,7 +9,10 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
from modules.repeater_manager import RepeaterManager
|
||||
from modules.repeater_manager import (
|
||||
RepeaterManager,
|
||||
collect_protected_pubkeys_for_device_mode,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -496,6 +499,7 @@ class TestCheckAndAutoPurge:
|
||||
rm.auto_purge_enabled = True
|
||||
rm.auto_purge_threshold = 10
|
||||
rm.companion_purge_enabled = True
|
||||
rm._auto_manage_contacts = 'bot'
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
|
||||
|
||||
@@ -510,6 +514,59 @@ class TestCheckAndAutoPurge:
|
||||
mock_comp.assert_called_once()
|
||||
assert result is True
|
||||
|
||||
async def test_companion_purge_skipped_when_auto_manage_device(self, bot):
|
||||
"""Device mode: count-based purge is suppressed; companions never auto-purged from radio."""
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
rm.companion_purge_enabled = True
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
|
||||
rm.bot.meshcore.commands = Mock()
|
||||
rm.bot.meshcore.commands.send_device_query = AsyncMock(return_value=Mock(type=Mock()))
|
||||
|
||||
with patch.object(rm, "_auto_purge_repeaters", new_callable=AsyncMock) as mock_rep, \
|
||||
patch.object(rm, "_auto_purge_companions", new_callable=AsyncMock) as mock_comp:
|
||||
result = await rm.check_and_auto_purge()
|
||||
|
||||
mock_rep.assert_not_called()
|
||||
mock_comp.assert_not_called()
|
||||
assert result is False
|
||||
assert rm.contact_limit >= 15
|
||||
assert rm.auto_purge_threshold == rm.contact_limit + 1
|
||||
|
||||
async def test_device_mode_returns_true_when_still_full_no_repeaters(self, bot):
|
||||
"""Device mode: no repeater purge path when mesh is within synced limit (no false failure)."""
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
rm.companion_purge_enabled = False
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
|
||||
rm.bot.meshcore.commands = Mock()
|
||||
rm.bot.meshcore.commands.send_device_query = AsyncMock(return_value=Mock(type=Mock()))
|
||||
|
||||
with patch.object(rm, "_auto_purge_repeaters", new_callable=AsyncMock) as mock_rep, \
|
||||
patch.object(rm, "_auto_purge_companions", new_callable=AsyncMock) as mock_comp:
|
||||
result = await rm.check_and_auto_purge()
|
||||
|
||||
mock_rep.assert_not_called()
|
||||
mock_comp.assert_not_called()
|
||||
assert result is False
|
||||
|
||||
async def test_device_mode_floors_contact_limit_to_mesh_when_firmware_under_reports(self, bot):
|
||||
"""If live contact count exceeds DEVICE_INFO max_contacts, raise limit to match the radio."""
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(331)}
|
||||
info = SimpleNamespace(type=EventType.DEVICE_INFO, payload={"max_contacts": 300})
|
||||
rm.bot.meshcore.commands = Mock()
|
||||
rm.bot.meshcore.commands.send_device_query = AsyncMock(return_value=info)
|
||||
|
||||
await rm._update_contact_limit_from_device()
|
||||
|
||||
assert rm.contact_limit == 331
|
||||
assert rm.auto_purge_threshold == 332
|
||||
|
||||
async def test_returns_false_when_purge_fails(self, rm):
|
||||
"""When both purge counts succeed=False, check_and_auto_purge returns False."""
|
||||
rm.auto_purge_enabled = True
|
||||
@@ -1302,3 +1359,240 @@ class TestPurgeDedupConcurrency:
|
||||
assert first_result is False
|
||||
assert second_result is True
|
||||
assert rm.bot.meshcore.commands.remove_contact.await_count == 2
|
||||
|
||||
|
||||
class TestCollectProtectedPubkeysForDeviceMode:
|
||||
"""collect_protected_pubkeys_for_device_mode matches Admin + announcements ACL union."""
|
||||
|
||||
def test_admin_and_announcements_merged(self, mock_logger):
|
||||
cfg = configparser.ConfigParser()
|
||||
pk_a = "aa" * 32
|
||||
pk_b = "bb" * 32
|
||||
cfg.add_section("Admin_ACL")
|
||||
cfg.set("Admin_ACL", "admin_pubkeys", pk_a)
|
||||
cfg.add_section("Announcements_Command")
|
||||
cfg.set("Announcements_Command", "announcements_acl", pk_b)
|
||||
keys = collect_protected_pubkeys_for_device_mode(cfg, mock_logger)
|
||||
assert keys == {pk_a.lower(), pk_b.lower()}
|
||||
|
||||
def test_admin_only_when_no_announcements_acl(self, mock_logger):
|
||||
cfg = configparser.ConfigParser()
|
||||
pk_a = "cc" * 32
|
||||
cfg.add_section("Admin_ACL")
|
||||
cfg.set("Admin_ACL", "admin_pubkeys", pk_a)
|
||||
cfg.add_section("Announcements_Command")
|
||||
cfg.set("Announcements_Command", "announcements_acl", "")
|
||||
keys = collect_protected_pubkeys_for_device_mode(cfg, mock_logger)
|
||||
assert keys == {pk_a.lower()}
|
||||
|
||||
|
||||
class TestAddCompanionFromContactData:
|
||||
"""RepeaterManager.add_companion_from_contact_data TABLE_FULL retry."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_after_table_full(self, rm, bot):
|
||||
pk = "dd" * 32
|
||||
contact_data = {
|
||||
"public_key": pk,
|
||||
"adv_name": "Bob",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
"last_advert": 0,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
}
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
err = SimpleNamespace(type=EventType.ERROR, payload={"error_code": 3, "code_string": "ERR_CODE_TABLE_FULL"})
|
||||
ok = SimpleNamespace(type=EventType.OK, payload={})
|
||||
bot.meshcore.commands.add_contact = AsyncMock(side_effect=[err, ok])
|
||||
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={
|
||||
"is_near_limit": False,
|
||||
"usage_percentage": 50.0,
|
||||
"current_contacts": 100,
|
||||
"estimated_limit": 300,
|
||||
}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock(return_value={"success": True})
|
||||
|
||||
result = await rm.add_companion_from_contact_data(contact_data, "Bob", pk)
|
||||
assert result is True
|
||||
assert bot.meshcore.commands.add_contact.await_count == 2
|
||||
rm.manage_contact_list.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_on_first_ok_without_retry(self, rm, bot):
|
||||
pk = "ee" * 32
|
||||
contact_data = {
|
||||
"public_key": pk,
|
||||
"adv_name": "Ann",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
"last_advert": 0,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
}
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
ok = SimpleNamespace(type=EventType.OK, payload={})
|
||||
bot.meshcore.commands.add_contact = AsyncMock(return_value=ok)
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={"is_near_limit": False, "usage_percentage": 10.0}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock()
|
||||
|
||||
result = await rm.add_companion_from_contact_data(contact_data, "Ann", pk)
|
||||
assert result is True
|
||||
bot.meshcore.commands.add_contact.assert_awaited_once()
|
||||
rm.manage_contact_list.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_limit_triggers_manage_before_add(self, rm, bot):
|
||||
pk = "ff" * 32
|
||||
contact_data = {
|
||||
"public_key": pk,
|
||||
"adv_name": "Near",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
"last_advert": 0,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
}
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
ok = SimpleNamespace(type=EventType.OK, payload={})
|
||||
bot.meshcore.commands.add_contact = AsyncMock(return_value=ok)
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={"is_near_limit": True, "usage_percentage": 85.0}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock(return_value={"success": True})
|
||||
|
||||
result = await rm.add_companion_from_contact_data(contact_data, "Near", pk)
|
||||
assert result is True
|
||||
rm.manage_contact_list.assert_awaited_once()
|
||||
bot.meshcore.commands.add_contact.assert_awaited_once()
|
||||
|
||||
|
||||
class TestApplyDeviceModeFirmwarePreferences:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_sets_manual_and_autoadd(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.set_manual_add_contacts = AsyncMock(return_value=ok)
|
||||
bot.meshcore.commands.set_autoadd_config = AsyncMock(return_value=ok)
|
||||
|
||||
assert await rm.apply_device_mode_firmware_preferences() is True
|
||||
bot.meshcore.commands.set_autoadd_config.assert_awaited_once_with(0x03)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_not_device_mode(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
rm = RepeaterManager(bot)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
|
||||
assert await rm.apply_device_mode_firmware_preferences() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_autoadd_not_ok(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bad = SimpleNamespace(type=EventType.ERROR, payload={})
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.set_manual_add_contacts = AsyncMock(return_value=ok)
|
||||
bot.meshcore.commands.set_autoadd_config = AsyncMock(return_value=bad)
|
||||
|
||||
assert await rm.apply_device_mode_firmware_preferences() is False
|
||||
|
||||
|
||||
class TestSyncDeviceModeFavourites:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass1_no_op_when_not_device(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
rm = RepeaterManager(bot)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.get_contacts = AsyncMock()
|
||||
bot.meshcore.commands.change_contact_flags = AsyncMock()
|
||||
|
||||
await rm.sync_device_mode_favourites_pass1()
|
||||
|
||||
bot.meshcore.commands.get_contacts.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass1_favourites_protected_contact(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
bot.config.set("Bot", "contact_flag_update_spacing_ms", "0")
|
||||
pk = "aa" * 32
|
||||
bot.config.add_section("Admin_ACL")
|
||||
bot.config.set("Admin_ACL", "admin_pubkeys", pk)
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.contacts = {pk: {"public_key": pk, "flags": 0, "adv_name": "A", "type": 1}}
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.get_contacts = AsyncMock()
|
||||
bot.meshcore.commands.change_contact_flags = AsyncMock(return_value=ok)
|
||||
|
||||
await rm.sync_device_mode_favourites_pass1()
|
||||
|
||||
bot.meshcore.commands.change_contact_flags.assert_awaited_once()
|
||||
args, _kwargs = bot.meshcore.commands.change_contact_flags.await_args
|
||||
assert args[1] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass2_clears_favourite_for_non_protected(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
bot.config.set("Bot", "contact_flag_update_spacing_ms", "0")
|
||||
pk_prot = "bb" * 32
|
||||
pk_other = "cc" * 32
|
||||
bot.config.add_section("Admin_ACL")
|
||||
bot.config.set("Admin_ACL", "admin_pubkeys", pk_prot)
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.contacts = {
|
||||
pk_other: {"public_key": pk_other, "flags": 1, "adv_name": "X", "type": 1},
|
||||
}
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.get_contacts = AsyncMock()
|
||||
bot.meshcore.commands.change_contact_flags = AsyncMock(return_value=ok)
|
||||
|
||||
await rm.sync_device_mode_favourites_pass2()
|
||||
|
||||
bot.meshcore.commands.change_contact_flags.assert_awaited_once()
|
||||
args, _kwargs = bot.meshcore.commands.change_contact_flags.await_args
|
||||
assert args[1] == 0
|
||||
|
||||
|
||||
class TestUpdateContactLimitFromDevice:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mode_uses_firmware_max_and_threshold(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
rm = RepeaterManager(bot)
|
||||
info = SimpleNamespace(type=EventType.DEVICE_INFO, payload={"max_contacts": 300})
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.contacts = {str(i): {} for i in range(10)}
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.send_device_query = AsyncMock(return_value=info)
|
||||
|
||||
await rm._update_contact_limit_from_device()
|
||||
|
||||
assert rm.contact_limit == 300
|
||||
assert rm.auto_purge_threshold == 280
|
||||
|
||||
@@ -1620,3 +1620,72 @@ class TestZombieAlertEmailSsrfGuard:
|
||||
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
|
||||
mock_smtp.assert_not_called()
|
||||
mock_smtp_ssl.assert_not_called()
|
||||
|
||||
|
||||
class TestDeviceModeSchedulerJobs:
|
||||
"""_setup_device_mode_scheduler_jobs registers one-shot jobs when auto_manage_contacts=device."""
|
||||
|
||||
def test_registers_three_jobs_in_device_mode(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
mock_ap = MagicMock()
|
||||
scheduler._apscheduler = mock_ap
|
||||
scheduler._setup_device_mode_scheduler_jobs()
|
||||
assert mock_ap.add_job.call_count == 3
|
||||
ids = {call.kwargs["id"] for call in mock_ap.add_job.call_args_list}
|
||||
assert ids == {
|
||||
"device_mode_firmware_autoadd",
|
||||
"device_mode_favourite_pass1",
|
||||
"device_mode_favourite_pass2",
|
||||
}
|
||||
for call in mock_ap.add_job.call_args_list:
|
||||
assert call.kwargs.get("replace_existing") is True
|
||||
|
||||
def test_no_jobs_when_not_device_mode(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
mock_ap = MagicMock()
|
||||
scheduler._apscheduler = mock_ap
|
||||
scheduler._setup_device_mode_scheduler_jobs()
|
||||
mock_ap.add_job.assert_not_called()
|
||||
|
||||
def test_firmware_job_sync_skips_when_not_device(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
with patch.object(scheduler, "_run_async_on_main_loop") as mock_run:
|
||||
scheduler._device_mode_firmware_job_sync()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_run_async_warns_without_running_loop(self, scheduler, mock_logger):
|
||||
scheduler.bot.logger = mock_logger
|
||||
scheduler.bot.main_event_loop = None
|
||||
|
||||
async def trivial():
|
||||
pass
|
||||
|
||||
coro = trivial()
|
||||
try:
|
||||
scheduler._run_async_on_main_loop(coro, timeout=1.0)
|
||||
finally:
|
||||
coro.close()
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_mode_firmware_coro_calls_repeater_manager(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
scheduler.bot.repeater_manager = Mock()
|
||||
scheduler.bot.repeater_manager.apply_device_mode_firmware_preferences = AsyncMock(return_value=True)
|
||||
|
||||
await scheduler._device_mode_firmware_coro()
|
||||
|
||||
scheduler.bot.repeater_manager.apply_device_mode_firmware_preferences.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_mode_favourite_coros_delegate(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
scheduler.bot.repeater_manager = Mock()
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass1 = AsyncMock()
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass2 = AsyncMock()
|
||||
|
||||
await scheduler._device_mode_favourite_pass1_coro()
|
||||
await scheduler._device_mode_favourite_pass2_coro()
|
||||
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass1.assert_awaited_once()
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass2.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user