This commit is contained in:
2026-01-01 15:25:23 -06:00
parent 9df89fe862
commit 23dbe7b789
52 changed files with 4540 additions and 2999 deletions

View File

@@ -7,7 +7,15 @@ class AnnounceManager:
def __init__(self, db: Database):
self.db = db
def upsert_announce(self, reticulum, identity, destination_hash, aspect, app_data, announce_packet_hash):
def upsert_announce(
self,
reticulum,
identity,
destination_hash,
aspect,
app_data,
announce_packet_hash,
):
# get rssi, snr and signal quality if available
rssi = reticulum.get_packet_rssi(announce_packet_hash)
snr = reticulum.get_packet_snr(announce_packet_hash)
@@ -15,7 +23,9 @@ class AnnounceManager:
# prepare data to insert or update
data = {
"destination_hash": destination_hash.hex() if isinstance(destination_hash, bytes) else destination_hash,
"destination_hash": destination_hash.hex()
if isinstance(destination_hash, bytes)
else destination_hash,
"aspect": aspect,
"identity_hash": identity.hash.hex(),
"identity_public_key": base64.b64encode(identity.get_public_key()).decode(
@@ -32,7 +42,14 @@ class AnnounceManager:
self.db.announces.upsert_announce(data)
def get_filtered_announces(self, aspect=None, identity_hash=None, destination_hash=None, query=None, blocked_identity_hashes=None):
def get_filtered_announces(
self,
aspect=None,
identity_hash=None,
destination_hash=None,
query=None,
blocked_identity_hashes=None,
):
sql = "SELECT * FROM announces WHERE 1=1"
params = []
@@ -56,4 +73,3 @@ class AnnounceManager:
sql += " ORDER BY updated_at DESC"
return self.db.provider.fetchall(sql, params)

View File

@@ -7,7 +7,9 @@ class ArchiverManager:
def __init__(self, db: Database):
self.db = db
def archive_page(self, destination_hash, page_path, content, max_versions=5, max_storage_gb=1):
def archive_page(
self, destination_hash, page_path, content, max_versions=5, max_storage_gb=1
):
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
# Check if already exists
@@ -27,18 +29,25 @@ class ArchiverManager:
# Delete older versions
to_delete = versions[max_versions:]
for version in to_delete:
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (version["id"],))
self.db.provider.execute(
"DELETE FROM archived_pages WHERE id = ?", (version["id"],)
)
# Enforce total storage limit (approximate)
total_size_row = self.db.provider.fetchone("SELECT SUM(LENGTH(content)) as total_size FROM archived_pages")
total_size_row = self.db.provider.fetchone(
"SELECT SUM(LENGTH(content)) as total_size FROM archived_pages"
)
total_size = total_size_row["total_size"] or 0
max_bytes = max_storage_gb * 1024 * 1024 * 1024
while total_size > max_bytes:
oldest = self.db.provider.fetchone("SELECT id, LENGTH(content) as size FROM archived_pages ORDER BY created_at ASC LIMIT 1")
oldest = self.db.provider.fetchone(
"SELECT id, LENGTH(content) as size FROM archived_pages ORDER BY created_at ASC LIMIT 1"
)
if oldest:
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (oldest["id"],))
self.db.provider.execute(
"DELETE FROM archived_pages WHERE id = ?", (oldest["id"],)
)
total_size -= oldest["size"]
else:
break

View File

@@ -1,4 +1,3 @@
class ConfigManager:
def __init__(self, db):
self.db = db
@@ -6,75 +5,139 @@ class ConfigManager:
# all possible config items
self.database_version = self.IntConfig(self, "database_version", None)
self.display_name = self.StringConfig(self, "display_name", "Anonymous Peer")
self.auto_announce_enabled = self.BoolConfig(self, "auto_announce_enabled", False)
self.auto_announce_interval_seconds = self.IntConfig(self, "auto_announce_interval_seconds", 0)
self.auto_announce_enabled = self.BoolConfig(
self, "auto_announce_enabled", False
)
self.auto_announce_interval_seconds = self.IntConfig(
self, "auto_announce_interval_seconds", 0
)
self.last_announced_at = self.IntConfig(self, "last_announced_at", None)
self.theme = self.StringConfig(self, "theme", "light")
self.language = self.StringConfig(self, "language", "en")
self.auto_resend_failed_messages_when_announce_received = self.BoolConfig(
self, "auto_resend_failed_messages_when_announce_received", True,
self,
"auto_resend_failed_messages_when_announce_received",
True,
)
self.allow_auto_resending_failed_messages_with_attachments = self.BoolConfig(
self, "allow_auto_resending_failed_messages_with_attachments", False,
self,
"allow_auto_resending_failed_messages_with_attachments",
False,
)
self.auto_send_failed_messages_to_propagation_node = self.BoolConfig(
self, "auto_send_failed_messages_to_propagation_node", False,
self,
"auto_send_failed_messages_to_propagation_node",
False,
)
self.show_suggested_community_interfaces = self.BoolConfig(
self, "show_suggested_community_interfaces", True,
self,
"show_suggested_community_interfaces",
True,
)
self.lxmf_delivery_transfer_limit_in_bytes = self.IntConfig(
self, "lxmf_delivery_transfer_limit_in_bytes", 1000 * 1000 * 10,
self,
"lxmf_delivery_transfer_limit_in_bytes",
1000 * 1000 * 10,
) # 10MB
self.lxmf_preferred_propagation_node_destination_hash = self.StringConfig(
self, "lxmf_preferred_propagation_node_destination_hash", None,
self,
"lxmf_preferred_propagation_node_destination_hash",
None,
)
self.lxmf_preferred_propagation_node_auto_sync_interval_seconds = self.IntConfig(
self, "lxmf_preferred_propagation_node_auto_sync_interval_seconds", 0,
self.lxmf_preferred_propagation_node_auto_sync_interval_seconds = (
self.IntConfig(
self,
"lxmf_preferred_propagation_node_auto_sync_interval_seconds",
0,
)
)
self.lxmf_preferred_propagation_node_last_synced_at = self.IntConfig(
self, "lxmf_preferred_propagation_node_last_synced_at", None,
self,
"lxmf_preferred_propagation_node_last_synced_at",
None,
)
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
self, "lxmf_local_propagation_node_enabled", False,
self,
"lxmf_local_propagation_node_enabled",
False,
)
self.lxmf_user_icon_name = self.StringConfig(self, "lxmf_user_icon_name", None)
self.lxmf_user_icon_foreground_colour = self.StringConfig(
self, "lxmf_user_icon_foreground_colour", None,
self,
"lxmf_user_icon_foreground_colour",
None,
)
self.lxmf_user_icon_background_colour = self.StringConfig(
self, "lxmf_user_icon_background_colour", None,
self,
"lxmf_user_icon_background_colour",
None,
)
self.lxmf_inbound_stamp_cost = self.IntConfig(
self, "lxmf_inbound_stamp_cost", 8,
self,
"lxmf_inbound_stamp_cost",
8,
) # for direct delivery messages
self.lxmf_propagation_node_stamp_cost = self.IntConfig(
self, "lxmf_propagation_node_stamp_cost", 16,
self,
"lxmf_propagation_node_stamp_cost",
16,
) # for propagation node messages
self.page_archiver_enabled = self.BoolConfig(self, "page_archiver_enabled", True)
self.page_archiver_max_versions = self.IntConfig(self, "page_archiver_max_versions", 5)
self.archives_max_storage_gb = self.IntConfig(self, "archives_max_storage_gb", 1)
self.page_archiver_enabled = self.BoolConfig(
self, "page_archiver_enabled", True
)
self.page_archiver_max_versions = self.IntConfig(
self, "page_archiver_max_versions", 5
)
self.archives_max_storage_gb = self.IntConfig(
self, "archives_max_storage_gb", 1
)
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
self.crawler_retry_delay_seconds = self.IntConfig(self, "crawler_retry_delay_seconds", 3600)
self.crawler_retry_delay_seconds = self.IntConfig(
self, "crawler_retry_delay_seconds", 3600
)
self.crawler_max_concurrent = self.IntConfig(self, "crawler_max_concurrent", 1)
self.auth_enabled = self.BoolConfig(self, "auth_enabled", False)
self.auth_password_hash = self.StringConfig(self, "auth_password_hash", None)
self.auth_session_secret = self.StringConfig(self, "auth_session_secret", None)
# voicemail config
self.voicemail_enabled = self.BoolConfig(self, "voicemail_enabled", False)
self.voicemail_greeting = self.StringConfig(
self,
"voicemail_greeting",
"Hello, I am not available right now. Please leave a message after the beep.",
)
self.voicemail_auto_answer_delay_seconds = self.IntConfig(
self,
"voicemail_auto_answer_delay_seconds",
20,
)
self.voicemail_max_recording_seconds = self.IntConfig(
self,
"voicemail_max_recording_seconds",
60,
)
# map config
self.map_offline_enabled = self.BoolConfig(self, "map_offline_enabled", False)
self.map_offline_path = self.StringConfig(self, "map_offline_path", None)
self.map_mbtiles_dir = self.StringConfig(self, "map_mbtiles_dir", None)
self.map_tile_cache_enabled = self.BoolConfig(self, "map_tile_cache_enabled", True)
self.map_tile_cache_enabled = self.BoolConfig(
self, "map_tile_cache_enabled", True
)
self.map_default_lat = self.StringConfig(self, "map_default_lat", "0.0")
self.map_default_lon = self.StringConfig(self, "map_default_lon", "0.0")
self.map_default_zoom = self.IntConfig(self, "map_default_zoom", 2)
self.map_tile_server_url = self.StringConfig(
self, "map_tile_server_url", "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
self,
"map_tile_server_url",
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
)
self.map_nominatim_api_url = self.StringConfig(
self, "map_nominatim_api_url", "https://nominatim.openstreetmap.org",
self,
"map_nominatim_api_url",
"https://nominatim.openstreetmap.org",
)
def get(self, key: str, default_value=None) -> str | None:
@@ -128,4 +191,3 @@ class ConfigManager:
def set(self, value: int):
self.manager.set(self.key, str(value))

View File

@@ -6,6 +6,7 @@ from .misc import MiscDAO
from .provider import DatabaseProvider
from .schema import DatabaseSchema
from .telephone import TelephoneDAO
from .voicemails import VoicemailDAO
class Database:
@@ -17,12 +18,15 @@ class Database:
self.announces = AnnounceDAO(self.provider)
self.misc = MiscDAO(self.provider)
self.telephone = TelephoneDAO(self.provider)
self.voicemails = VoicemailDAO(self.provider)
def initialize(self):
self.schema.initialize()
def migrate_from_legacy(self, reticulum_config_dir, identity_hash_hex):
migrator = LegacyMigrator(self.provider, reticulum_config_dir, identity_hash_hex)
migrator = LegacyMigrator(
self.provider, reticulum_config_dir, identity_hash_hex
)
if migrator.should_migrate():
return migrator.migrate()
return False
@@ -32,4 +36,3 @@ class Database:
def close(self):
self.provider.close()

View File

@@ -13,16 +13,26 @@ class AnnounceDAO:
data = dict(data)
fields = [
"destination_hash", "aspect", "identity_hash", "identity_public_key",
"app_data", "rssi", "snr", "quality",
"destination_hash",
"aspect",
"identity_hash",
"identity_public_key",
"app_data",
"rssi",
"snr",
"quality",
]
# These are safe as they are from a hardcoded list
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "destination_hash"])
update_set = ", ".join(
[f"{f} = EXCLUDED.{f}" for f in fields if f != "destination_hash"]
)
query = f"INSERT INTO announces ({columns}, updated_at) VALUES ({placeholders}, ?) " \
f"ON CONFLICT(destination_hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608
query = (
f"INSERT INTO announces ({columns}, updated_at) VALUES ({placeholders}, ?) "
f"ON CONFLICT(destination_hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at"
) # noqa: S608
params = [data.get(f) for f in fields]
params.append(datetime.now(UTC))
@@ -30,13 +40,19 @@ class AnnounceDAO:
def get_announces(self, aspect=None):
if aspect:
return self.provider.fetchall("SELECT * FROM announces WHERE aspect = ?", (aspect,))
return self.provider.fetchall(
"SELECT * FROM announces WHERE aspect = ?", (aspect,)
)
return self.provider.fetchall("SELECT * FROM announces")
def get_announce_by_hash(self, destination_hash):
return self.provider.fetchone("SELECT * FROM announces WHERE destination_hash = ?", (destination_hash,))
return self.provider.fetchone(
"SELECT * FROM announces WHERE destination_hash = ?", (destination_hash,)
)
def get_filtered_announces(self, aspect=None, search_term=None, limit=None, offset=0):
def get_filtered_announces(
self, aspect=None, search_term=None, limit=None, offset=0
):
query = "SELECT * FROM announces WHERE 1=1"
params = []
if aspect:
@@ -58,33 +74,49 @@ class AnnounceDAO:
# Custom Display Names
def upsert_custom_display_name(self, destination_hash, display_name):
now = datetime.now(UTC)
self.provider.execute("""
self.provider.execute(
"""
INSERT INTO custom_destination_display_names (destination_hash, display_name, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, updated_at = EXCLUDED.updated_at
""", (destination_hash, display_name, now))
""",
(destination_hash, display_name, now),
)
def get_custom_display_name(self, destination_hash):
row = self.provider.fetchone("SELECT display_name FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,))
row = self.provider.fetchone(
"SELECT display_name FROM custom_destination_display_names WHERE destination_hash = ?",
(destination_hash,),
)
return row["display_name"] if row else None
def delete_custom_display_name(self, destination_hash):
self.provider.execute("DELETE FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,))
self.provider.execute(
"DELETE FROM custom_destination_display_names WHERE destination_hash = ?",
(destination_hash,),
)
# Favourites
def upsert_favourite(self, destination_hash, display_name, aspect):
now = datetime.now(UTC)
self.provider.execute("""
self.provider.execute(
"""
INSERT INTO favourite_destinations (destination_hash, display_name, aspect, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at
""", (destination_hash, display_name, aspect, now))
""",
(destination_hash, display_name, aspect, now),
)
def get_favourites(self, aspect=None):
if aspect:
return self.provider.fetchall("SELECT * FROM favourite_destinations WHERE aspect = ?", (aspect,))
return self.provider.fetchall(
"SELECT * FROM favourite_destinations WHERE aspect = ?", (aspect,)
)
return self.provider.fetchall("SELECT * FROM favourite_destinations")
def delete_favourite(self, destination_hash):
self.provider.execute("DELETE FROM favourite_destinations WHERE destination_hash = ?", (destination_hash,))
self.provider.execute(
"DELETE FROM favourite_destinations WHERE destination_hash = ?",
(destination_hash,),
)

View File

@@ -24,4 +24,3 @@ class ConfigDAO:
def delete(self, key):
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))

View File

@@ -8,8 +8,7 @@ class LegacyMigrator:
self.identity_hash_hex = identity_hash_hex
def get_legacy_db_path(self):
"""Detect the path to the legacy database based on the Reticulum config directory.
"""
"""Detect the path to the legacy database based on the Reticulum config directory."""
possible_dirs = []
if self.reticulum_config_dir:
possible_dirs.append(self.reticulum_config_dir)
@@ -21,7 +20,9 @@ class LegacyMigrator:
# Check each directory
for config_dir in possible_dirs:
legacy_path = os.path.join(config_dir, "identities", self.identity_hash_hex, "database.db")
legacy_path = os.path.join(
config_dir, "identities", self.identity_hash_hex, "database.db"
)
if os.path.exists(legacy_path):
# Ensure it's not the same as our current DB path
# (though this is unlikely given the different base directories)
@@ -58,8 +59,7 @@ class LegacyMigrator:
return True
def migrate(self):
"""Perform the migration from the legacy database.
"""
"""Perform the migration from the legacy database."""
legacy_path = self.get_legacy_db_path()
if not legacy_path:
return False
@@ -100,11 +100,23 @@ class LegacyMigrator:
if res:
# Get columns from both databases to ensure compatibility
# These PRAGMA calls are safe as they use controlled table/alias names
legacy_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA {alias}.table_info({table})")]
current_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA table_info({table})")]
legacy_columns = [
row["name"]
for row in self.provider.fetchall(
f"PRAGMA {alias}.table_info({table})"
)
]
current_columns = [
row["name"]
for row in self.provider.fetchall(
f"PRAGMA table_info({table})"
)
]
# Find common columns
common_columns = [col for col in legacy_columns if col in current_columns]
common_columns = [
col for col in legacy_columns if col in current_columns
]
if common_columns:
cols_str = ", ".join(common_columns)
@@ -112,9 +124,13 @@ class LegacyMigrator:
# The table and columns are controlled by us
migrate_query = f"INSERT OR IGNORE INTO {table} ({cols_str}) SELECT {cols_str} FROM {alias}.{table}" # noqa: S608
self.provider.execute(migrate_query)
print(f" - Migrated table: {table} ({len(common_columns)} columns)")
print(
f" - Migrated table: {table} ({len(common_columns)} columns)"
)
else:
print(f" - Skipping table {table}: No common columns found")
print(
f" - Skipping table {table}: No common columns found"
)
except Exception as e:
print(f" - Failed to migrate table {table}: {e}")

View File

@@ -15,17 +15,33 @@ class MessageDAO:
# Ensure all required fields are present and handle defaults
fields = [
"hash", "source_hash", "destination_hash", "state", "progress",
"is_incoming", "method", "delivery_attempts", "next_delivery_attempt_at",
"title", "content", "fields", "timestamp", "rssi", "snr", "quality", "is_spam",
"hash",
"source_hash",
"destination_hash",
"state",
"progress",
"is_incoming",
"method",
"delivery_attempts",
"next_delivery_attempt_at",
"title",
"content",
"fields",
"timestamp",
"rssi",
"snr",
"quality",
"is_spam",
]
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "hash"])
query = f"INSERT INTO lxmf_messages ({columns}, updated_at) VALUES ({placeholders}, ?) " \
f"ON CONFLICT(hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608
query = (
f"INSERT INTO lxmf_messages ({columns}, updated_at) VALUES ({placeholders}, ?) "
f"ON CONFLICT(hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at"
) # noqa: S608
params = []
for f in fields:
@@ -38,10 +54,14 @@ class MessageDAO:
self.provider.execute(query, params)
def get_lxmf_message_by_hash(self, message_hash):
return self.provider.fetchone("SELECT * FROM lxmf_messages WHERE hash = ?", (message_hash,))
return self.provider.fetchone(
"SELECT * FROM lxmf_messages WHERE hash = ?", (message_hash,)
)
def delete_lxmf_message_by_hash(self, message_hash):
self.provider.execute("DELETE FROM lxmf_messages WHERE hash = ?", (message_hash,))
self.provider.execute(
"DELETE FROM lxmf_messages WHERE hash = ?", (message_hash,)
)
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
return self.provider.fetchall(
@@ -73,13 +93,16 @@ class MessageDAO:
)
def is_conversation_unread(self, destination_hash):
row = self.provider.fetchone("""
row = self.provider.fetchone(
"""
SELECT m.timestamp, r.last_read_at
FROM lxmf_messages m
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = ?
WHERE (m.destination_hash = ? OR m.source_hash = ?)
ORDER BY m.timestamp DESC LIMIT 1
""", (destination_hash, destination_hash, destination_hash))
""",
(destination_hash, destination_hash, destination_hash),
)
if not row:
return False
@@ -93,13 +116,16 @@ class MessageDAO:
return row["timestamp"] > last_read_at.timestamp()
def mark_stuck_messages_as_failed(self):
self.provider.execute("""
self.provider.execute(
"""
UPDATE lxmf_messages
SET state = 'failed', updated_at = ?
WHERE state = 'outbound'
OR (state = 'sent' AND method = 'opportunistic')
OR state = 'sending'
""", (datetime.now(UTC).isoformat(),))
""",
(datetime.now(UTC).isoformat(),),
)
def get_failed_messages_for_destination(self, destination_hash):
return self.provider.fetchall(
@@ -115,9 +141,14 @@ class MessageDAO:
return row["count"] if row else 0
# Forwarding Mappings
def get_forwarding_mapping(self, alias_hash=None, original_sender_hash=None, final_recipient_hash=None):
def get_forwarding_mapping(
self, alias_hash=None, original_sender_hash=None, final_recipient_hash=None
):
if alias_hash:
return self.provider.fetchone("SELECT * FROM lxmf_forwarding_mappings WHERE alias_hash = ?", (alias_hash,))
return self.provider.fetchone(
"SELECT * FROM lxmf_forwarding_mappings WHERE alias_hash = ?",
(alias_hash,),
)
if original_sender_hash and final_recipient_hash:
return self.provider.fetchone(
"SELECT * FROM lxmf_forwarding_mappings WHERE original_sender_hash = ? AND final_recipient_hash = ?",
@@ -131,8 +162,11 @@ class MessageDAO:
data = dict(data)
fields = [
"alias_identity_private_key", "alias_hash", "original_sender_hash",
"final_recipient_hash", "original_destination_hash",
"alias_identity_private_key",
"alias_hash",
"original_sender_hash",
"final_recipient_hash",
"original_destination_hash",
]
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
@@ -143,4 +177,3 @@ class MessageDAO:
def get_all_forwarding_mappings(self):
return self.provider.fetchall("SELECT * FROM lxmf_forwarding_mappings")

View File

@@ -15,13 +15,22 @@ class MiscDAO:
)
def is_destination_blocked(self, destination_hash):
return self.provider.fetchone("SELECT 1 FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,)) is not None
return (
self.provider.fetchone(
"SELECT 1 FROM blocked_destinations WHERE destination_hash = ?",
(destination_hash,),
)
is not None
)
def get_blocked_destinations(self):
return self.provider.fetchall("SELECT * FROM blocked_destinations")
def delete_blocked_destination(self, destination_hash):
self.provider.execute("DELETE FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,))
self.provider.execute(
"DELETE FROM blocked_destinations WHERE destination_hash = ?",
(destination_hash,),
)
# Spam Keywords
def add_spam_keyword(self, keyword):
@@ -45,9 +54,12 @@ class MiscDAO:
return False
# User Icons
def update_lxmf_user_icon(self, destination_hash, icon_name, foreground_colour, background_colour):
def update_lxmf_user_icon(
self, destination_hash, icon_name, foreground_colour, background_colour
):
now = datetime.now(UTC)
self.provider.execute("""
self.provider.execute(
"""
INSERT INTO lxmf_user_icons (destination_hash, icon_name, foreground_colour, background_colour, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET
@@ -55,10 +67,15 @@ class MiscDAO:
foreground_colour = EXCLUDED.foreground_colour,
background_colour = EXCLUDED.background_colour,
updated_at = EXCLUDED.updated_at
""", (destination_hash, icon_name, foreground_colour, background_colour, now))
""",
(destination_hash, icon_name, foreground_colour, background_colour, now),
)
def get_user_icon(self, destination_hash):
return self.provider.fetchone("SELECT * FROM lxmf_user_icons WHERE destination_hash = ?", (destination_hash,))
return self.provider.fetchone(
"SELECT * FROM lxmf_user_icons WHERE destination_hash = ?",
(destination_hash,),
)
# Forwarding Rules
def get_forwarding_rules(self, identity_hash=None, active_only=False):
@@ -71,18 +88,31 @@ class MiscDAO:
query += " AND is_active = 1"
return self.provider.fetchall(query, params)
def create_forwarding_rule(self, identity_hash, forward_to_hash, source_filter_hash, is_active=True):
def create_forwarding_rule(
self, identity_hash, forward_to_hash, source_filter_hash, is_active=True
):
now = datetime.now(UTC)
self.provider.execute(
"INSERT INTO lxmf_forwarding_rules (identity_hash, forward_to_hash, source_filter_hash, is_active, updated_at) VALUES (?, ?, ?, ?, ?)",
(identity_hash, forward_to_hash, source_filter_hash, 1 if is_active else 0, now),
(
identity_hash,
forward_to_hash,
source_filter_hash,
1 if is_active else 0,
now,
),
)
def delete_forwarding_rule(self, rule_id):
self.provider.execute("DELETE FROM lxmf_forwarding_rules WHERE id = ?", (rule_id,))
self.provider.execute(
"DELETE FROM lxmf_forwarding_rules WHERE id = ?", (rule_id,)
)
def toggle_forwarding_rule(self, rule_id):
self.provider.execute("UPDATE lxmf_forwarding_rules SET is_active = NOT is_active WHERE id = ?", (rule_id,))
self.provider.execute(
"UPDATE lxmf_forwarding_rules SET is_active = NOT is_active WHERE id = ?",
(rule_id,),
)
# Archived Pages
def archive_page(self, destination_hash, page_path, content, page_hash):
@@ -105,7 +135,9 @@ class MiscDAO:
params.append(destination_hash)
if query:
like_term = f"%{query}%"
sql += " AND (destination_hash LIKE ? OR page_path LIKE ? OR content LIKE ?)"
sql += (
" AND (destination_hash LIKE ? OR page_path LIKE ? OR content LIKE ?)"
)
params.extend([like_term, like_term, like_term])
sql += " ORDER BY created_at DESC"
@@ -113,25 +145,41 @@ class MiscDAO:
def delete_archived_pages(self, destination_hash=None, page_path=None):
if destination_hash and page_path:
self.provider.execute("DELETE FROM archived_pages WHERE destination_hash = ? AND page_path = ?", (destination_hash, page_path))
self.provider.execute(
"DELETE FROM archived_pages WHERE destination_hash = ? AND page_path = ?",
(destination_hash, page_path),
)
else:
self.provider.execute("DELETE FROM archived_pages")
# Crawl Tasks
def upsert_crawl_task(self, destination_hash, page_path, status="pending", retry_count=0):
self.provider.execute("""
def upsert_crawl_task(
self, destination_hash, page_path, status="pending", retry_count=0
):
self.provider.execute(
"""
INSERT INTO crawl_tasks (destination_hash, page_path, status, retry_count)
VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash, page_path) DO UPDATE SET
status = EXCLUDED.status,
retry_count = EXCLUDED.retry_count
""", (destination_hash, page_path, status, retry_count))
""",
(destination_hash, page_path, status, retry_count),
)
def get_pending_crawl_tasks(self):
return self.provider.fetchall("SELECT * FROM crawl_tasks WHERE status = 'pending'")
return self.provider.fetchall(
"SELECT * FROM crawl_tasks WHERE status = 'pending'"
)
def update_crawl_task(self, task_id, **kwargs):
allowed_keys = {"destination_hash", "page_path", "status", "retry_count", "updated_at"}
allowed_keys = {
"destination_hash",
"page_path",
"status",
"retry_count",
"updated_at",
}
filtered_kwargs = {k: v for k, v in kwargs.items() if k in allowed_keys}
if not filtered_kwargs:
@@ -150,5 +198,6 @@ class MiscDAO:
)
def get_archived_page_by_id(self, archive_id):
return self.provider.fetchone("SELECT * FROM archived_pages WHERE id = ?", (archive_id,))
return self.provider.fetchone(
"SELECT * FROM archived_pages WHERE id = ?", (archive_id,)
)

View File

@@ -23,7 +23,9 @@ class DatabaseProvider:
@property
def connection(self):
if not hasattr(self._local, "connection"):
self._local.connection = sqlite3.connect(self.db_path, check_same_thread=False)
self._local.connection = sqlite3.connect(
self.db_path, check_same_thread=False
)
self._local.connection.row_factory = sqlite3.Row
# Enable WAL mode for better concurrency
self._local.connection.execute("PRAGMA journal_mode=WAL")
@@ -62,4 +64,3 @@ class DatabaseProvider:
def checkpoint(self):
return self.fetchall("PRAGMA wal_checkpoint(TRUNCATE)")

View File

@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
LATEST_VERSION = 12
LATEST_VERSION = 13
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -16,7 +16,9 @@ class DatabaseSchema:
self.migrate(current_version)
def _get_current_version(self):
row = self.provider.fetchone("SELECT value FROM config WHERE key = ?", ("database_version",))
row = self.provider.fetchone(
"SELECT value FROM config WHERE key = ?", ("database_version",)
)
if row:
return int(row["value"])
return 0
@@ -189,21 +191,45 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"voicemails": """
CREATE TABLE IF NOT EXISTS voicemails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remote_identity_hash TEXT,
remote_identity_name TEXT,
filename TEXT,
duration_seconds INTEGER,
is_read INTEGER DEFAULT 0,
timestamp REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
}
for table_name, create_sql in tables.items():
self.provider.execute(create_sql)
# Create indexes that were present
if table_name == "announces":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_aspect ON announces(aspect)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_identity_hash ON announces(identity_hash)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_announces_aspect ON announces(aspect)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_announces_identity_hash ON announces(identity_hash)"
)
elif table_name == "lxmf_messages":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_source_hash ON lxmf_messages(source_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_destination_hash ON lxmf_messages(destination_hash)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_messages_source_hash ON lxmf_messages(source_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_messages_destination_hash ON lxmf_messages(destination_hash)"
)
elif table_name == "blocked_destinations":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_blocked_destinations_hash ON blocked_destinations(destination_hash)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_blocked_destinations_hash ON blocked_destinations(destination_hash)"
)
elif table_name == "spam_keywords":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_spam_keywords_keyword ON spam_keywords(keyword)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_spam_keywords_keyword ON spam_keywords(keyword)"
)
def migrate(self, current_version):
if current_version < 7:
@@ -217,9 +243,15 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_destination_hash ON archived_pages(destination_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_page_path ON archived_pages(page_path)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_hash ON archived_pages(hash)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_archived_pages_destination_hash ON archived_pages(destination_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_archived_pages_page_path ON archived_pages(page_path)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_archived_pages_hash ON archived_pages(hash)"
)
if current_version < 8:
self.provider.execute("""
@@ -234,8 +266,12 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_destination_hash ON crawl_tasks(destination_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_page_path ON crawl_tasks(page_path)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_crawl_tasks_destination_hash ON crawl_tasks(destination_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_crawl_tasks_page_path ON crawl_tasks(page_path)"
)
if current_version < 9:
self.provider.execute("""
@@ -249,7 +285,9 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_rules_identity_hash ON lxmf_forwarding_rules(identity_hash)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_rules_identity_hash ON lxmf_forwarding_rules(identity_hash)"
)
self.provider.execute("""
CREATE TABLE IF NOT EXISTS lxmf_forwarding_mappings (
@@ -262,9 +300,15 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_alias_hash ON lxmf_forwarding_mappings(alias_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_sender_hash ON lxmf_forwarding_mappings(original_sender_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_recipient_hash ON lxmf_forwarding_mappings(final_recipient_hash)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_alias_hash ON lxmf_forwarding_mappings(alias_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_sender_hash ON lxmf_forwarding_mappings(original_sender_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_recipient_hash ON lxmf_forwarding_mappings(final_recipient_hash)"
)
if current_version < 10:
# Ensure unique constraints exist for ON CONFLICT clauses
@@ -272,26 +316,56 @@ class DatabaseSchema:
# but a UNIQUE index works for ON CONFLICT.
# Clean up duplicates before adding unique indexes
self.provider.execute("DELETE FROM announces WHERE id NOT IN (SELECT MAX(id) FROM announces GROUP BY destination_hash)")
self.provider.execute("DELETE FROM crawl_tasks WHERE id NOT IN (SELECT MAX(id) FROM crawl_tasks GROUP BY destination_hash, page_path)")
self.provider.execute("DELETE FROM custom_destination_display_names WHERE id NOT IN (SELECT MAX(id) FROM custom_destination_display_names GROUP BY destination_hash)")
self.provider.execute("DELETE FROM favourite_destinations WHERE id NOT IN (SELECT MAX(id) FROM favourite_destinations GROUP BY destination_hash)")
self.provider.execute("DELETE FROM lxmf_user_icons WHERE id NOT IN (SELECT MAX(id) FROM lxmf_user_icons GROUP BY destination_hash)")
self.provider.execute("DELETE FROM lxmf_conversation_read_state WHERE id NOT IN (SELECT MAX(id) FROM lxmf_conversation_read_state GROUP BY destination_hash)")
self.provider.execute("DELETE FROM lxmf_messages WHERE id NOT IN (SELECT MAX(id) FROM lxmf_messages GROUP BY hash)")
self.provider.execute(
"DELETE FROM announces WHERE id NOT IN (SELECT MAX(id) FROM announces GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM crawl_tasks WHERE id NOT IN (SELECT MAX(id) FROM crawl_tasks GROUP BY destination_hash, page_path)"
)
self.provider.execute(
"DELETE FROM custom_destination_display_names WHERE id NOT IN (SELECT MAX(id) FROM custom_destination_display_names GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM favourite_destinations WHERE id NOT IN (SELECT MAX(id) FROM favourite_destinations GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM lxmf_user_icons WHERE id NOT IN (SELECT MAX(id) FROM lxmf_user_icons GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM lxmf_conversation_read_state WHERE id NOT IN (SELECT MAX(id) FROM lxmf_conversation_read_state GROUP BY destination_hash)"
)
self.provider.execute(
"DELETE FROM lxmf_messages WHERE id NOT IN (SELECT MAX(id) FROM lxmf_messages GROUP BY hash)"
)
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_announces_destination_hash_unique ON announces(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_crawl_tasks_destination_path_unique ON crawl_tasks(destination_hash, page_path)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_display_names_dest_hash_unique ON custom_destination_display_names(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_favourite_destinations_dest_hash_unique ON favourite_destinations(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_messages_hash_unique ON lxmf_messages(hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_user_icons_dest_hash_unique ON lxmf_user_icons(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_conversation_read_state_dest_hash_unique ON lxmf_conversation_read_state(destination_hash)")
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_announces_destination_hash_unique ON announces(destination_hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_crawl_tasks_destination_path_unique ON crawl_tasks(destination_hash, page_path)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_display_names_dest_hash_unique ON custom_destination_display_names(destination_hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_favourite_destinations_dest_hash_unique ON favourite_destinations(destination_hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_messages_hash_unique ON lxmf_messages(hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_user_icons_dest_hash_unique ON lxmf_user_icons(destination_hash)"
)
self.provider.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_conversation_read_state_dest_hash_unique ON lxmf_conversation_read_state(destination_hash)"
)
if current_version < 11:
# Add is_spam column to lxmf_messages if it doesn't exist
try:
self.provider.execute("ALTER TABLE lxmf_messages ADD COLUMN is_spam INTEGER DEFAULT 0")
self.provider.execute(
"ALTER TABLE lxmf_messages ADD COLUMN is_spam INTEGER DEFAULT 0"
)
except Exception:
# Column might already exist if table was created with newest schema
pass
@@ -309,9 +383,35 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_remote_hash ON call_history(remote_identity_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_timestamp ON call_history(timestamp)")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_call_history_remote_hash ON call_history(remote_identity_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_call_history_timestamp ON call_history(timestamp)"
)
if current_version < 13:
self.provider.execute("""
CREATE TABLE IF NOT EXISTS voicemails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remote_identity_hash TEXT,
remote_identity_name TEXT,
filename TEXT,
duration_seconds INTEGER,
is_read INTEGER DEFAULT 0,
timestamp REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_voicemails_remote_hash ON voicemails(remote_identity_hash)"
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_voicemails_timestamp ON voicemails(timestamp)"
)
# Update version in config
self.provider.execute("INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", ("database_version", str(self.LATEST_VERSION)))
self.provider.execute(
"INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
("database_version", str(self.LATEST_VERSION)),
)

View File

@@ -1,4 +1,3 @@
from .provider import DatabaseProvider
@@ -41,4 +40,3 @@ class TelephoneDAO:
"SELECT * FROM call_history ORDER BY timestamp DESC LIMIT ?",
(limit,),
)

View File

@@ -0,0 +1,63 @@
from .provider import DatabaseProvider
class VoicemailDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def add_voicemail(
self,
remote_identity_hash,
remote_identity_name,
filename,
duration_seconds,
timestamp,
):
self.provider.execute(
"""
INSERT INTO voicemails (
remote_identity_hash,
remote_identity_name,
filename,
duration_seconds,
timestamp
) VALUES (?, ?, ?, ?, ?)
""",
(
remote_identity_hash,
remote_identity_name,
filename,
duration_seconds,
timestamp,
),
)
def get_voicemails(self, limit=50, offset=0):
return self.provider.fetchall(
"SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset),
)
def get_voicemail(self, voicemail_id):
return self.provider.fetchone(
"SELECT * FROM voicemails WHERE id = ?",
(voicemail_id,),
)
def mark_as_read(self, voicemail_id):
self.provider.execute(
"UPDATE voicemails SET is_read = 1 WHERE id = ?",
(voicemail_id,),
)
def delete_voicemail(self, voicemail_id):
self.provider.execute(
"DELETE FROM voicemails WHERE id = ?",
(voicemail_id,),
)
def get_unread_count(self):
row = self.provider.fetchone(
"SELECT COUNT(*) as count FROM voicemails WHERE is_read = 0"
)
return row["count"] if row else 0

View File

@@ -15,14 +15,20 @@ class ForwardingManager:
mappings = self.db.messages.get_all_forwarding_mappings()
for mapping in mappings:
try:
private_key_bytes = base64.b64decode(mapping["alias_identity_private_key"])
private_key_bytes = base64.b64decode(
mapping["alias_identity_private_key"]
)
alias_identity = RNS.Identity.from_bytes(private_key_bytes)
alias_destination = self.message_router.register_delivery_identity(identity=alias_identity)
alias_destination = self.message_router.register_delivery_identity(
identity=alias_identity
)
self.forwarding_destinations[mapping["alias_hash"]] = alias_destination
except Exception as e:
print(f"Failed to load forwarding alias {mapping['alias_hash']}: {e}")
def get_or_create_mapping(self, source_hash, final_recipient_hash, original_destination_hash):
def get_or_create_mapping(
self, source_hash, final_recipient_hash, original_destination_hash
):
mapping = self.db.messages.get_forwarding_mapping(
original_sender_hash=source_hash,
final_recipient_hash=final_recipient_hash,
@@ -32,11 +38,15 @@ class ForwardingManager:
alias_identity = RNS.Identity()
alias_hash = alias_identity.hash.hex()
alias_destination = self.message_router.register_delivery_identity(alias_identity)
alias_destination = self.message_router.register_delivery_identity(
alias_identity
)
self.forwarding_destinations[alias_hash] = alias_destination
data = {
"alias_identity_private_key": base64.b64encode(alias_identity.get_private_key()).decode(),
"alias_identity_private_key": base64.b64encode(
alias_identity.get_private_key()
).decode(),
"alias_hash": alias_hash,
"original_sender_hash": source_hash,
"final_recipient_hash": final_recipient_hash,
@@ -45,4 +55,3 @@ class ForwardingManager:
self.db.messages.create_forwarding_mapping(data)
return data
return mapping

View File

@@ -55,13 +55,15 @@ class MapManager:
if f.endswith(".mbtiles"):
full_path = os.path.join(mbtiles_dir, f)
stats = os.stat(full_path)
files.append({
"name": f,
"path": full_path,
"size": stats.st_size,
"mtime": stats.st_mtime,
"is_active": full_path == self.get_offline_path(),
})
files.append(
{
"name": f,
"path": full_path,
"size": stats.st_size,
"mtime": stats.st_mtime,
"is_active": full_path == self.get_offline_path(),
}
)
return sorted(files, key=lambda x: x["mtime"], reverse=True)
def delete_mbtiles(self, filename):
@@ -97,7 +99,10 @@ class MapManager:
# Basic validation: ensure it's raster (format is not pbf)
if metadata.get("format") == "pbf":
RNS.log("MBTiles file is in vector (PBF) format, which is not supported.", RNS.LOG_ERROR)
RNS.log(
"MBTiles file is in vector (PBF) format, which is not supported.",
RNS.LOG_ERROR,
)
return None
self._metadata_cache = metadata
@@ -176,8 +181,12 @@ class MapManager:
# create schema
cursor.execute("CREATE TABLE metadata (name text, value text)")
cursor.execute("CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)")
cursor.execute("CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row)")
cursor.execute(
"CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)"
)
cursor.execute(
"CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row)"
)
# insert metadata
metadata = [
@@ -205,7 +214,11 @@ class MapManager:
# wait a bit to be nice to OSM
time.sleep(0.1)
response = requests.get(tile_url, headers={"User-Agent": "MeshChatX/1.0 MapExporter"}, timeout=10)
response = requests.get(
tile_url,
headers={"User-Agent": "MeshChatX/1.0 MapExporter"},
timeout=10,
)
if response.status_code == 200:
# MBTiles uses TMS (y flipped)
tms_y = (1 << z) - 1 - y
@@ -214,11 +227,16 @@ class MapManager:
(z, x, tms_y, response.content),
)
except Exception as e:
RNS.log(f"Export failed to download tile {z}/{x}/{y}: {e}", RNS.LOG_ERROR)
RNS.log(
f"Export failed to download tile {z}/{x}/{y}: {e}",
RNS.LOG_ERROR,
)
current_count += 1
self._export_progress[export_id]["current"] = current_count
self._export_progress[export_id]["progress"] = int((current_count / total_tiles) * 100)
self._export_progress[export_id]["progress"] = int(
(current_count / total_tiles) * 100
)
# commit after each zoom level
conn.commit()
@@ -236,9 +254,13 @@ class MapManager:
def _lonlat_to_tile(self, lon, lat, zoom):
lat_rad = math.radians(lat)
n = 2.0 ** zoom
n = 2.0**zoom
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
y = int(
(1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi)
/ 2.0
* n
)
return x, y
def close(self):

View File

@@ -5,7 +5,15 @@ class MessageHandler:
def __init__(self, db: Database):
self.db = db
def get_conversation_messages(self, local_hash, destination_hash, limit=100, offset=0, after_id=None, before_id=None):
def get_conversation_messages(
self,
local_hash,
destination_hash,
limit=100,
offset=0,
after_id=None,
before_id=None,
):
query = """
SELECT * FROM lxmf_messages
WHERE ((source_hash = ? AND destination_hash = ?)
@@ -31,7 +39,9 @@ class MessageHandler:
WHERE ((source_hash = ? AND destination_hash = ?)
OR (destination_hash = ? AND source_hash = ?))
"""
self.db.provider.execute(query, [local_hash, destination_hash, local_hash, destination_hash])
self.db.provider.execute(
query, [local_hash, destination_hash, local_hash, destination_hash]
)
def search_messages(self, local_hash, search_term):
like_term = f"%{search_term}%"
@@ -61,6 +71,12 @@ class MessageHandler:
WHERE m1.source_hash = ? OR m1.destination_hash = ?
ORDER BY m1.timestamp DESC
"""
params = [local_hash, local_hash, local_hash, local_hash, local_hash, local_hash]
params = [
local_hash,
local_hash,
local_hash,
local_hash,
local_hash,
local_hash,
]
return self.db.provider.fetchall(query, params)

View File

@@ -22,9 +22,17 @@ class RNCPHandler:
self.allow_overwrite_on_receive = False
self.allowed_identity_hashes = []
def setup_receive_destination(self, allowed_hashes=None, fetch_allowed=False, fetch_jail=None, allow_overwrite=False):
def setup_receive_destination(
self,
allowed_hashes=None,
fetch_allowed=False,
fetch_jail=None,
allow_overwrite=False,
):
if allowed_hashes:
self.allowed_identity_hashes = [bytes.fromhex(h) if isinstance(h, str) else h for h in allowed_hashes]
self.allowed_identity_hashes = [
bytes.fromhex(h) if isinstance(h, str) else h for h in allowed_hashes
]
self.fetch_jail = fetch_jail
self.allow_overwrite_on_receive = allow_overwrite
@@ -44,7 +52,9 @@ class RNCPHandler:
"receive",
)
self.receive_destination.set_link_established_callback(self._client_link_established)
self.receive_destination.set_link_established_callback(
self._client_link_established
)
if fetch_allowed:
self.receive_destination.register_request_handler(
@@ -86,7 +96,9 @@ class RNCPHandler:
if resource.status == RNS.Resource.COMPLETE:
if resource.metadata:
try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
filename = os.path.basename(
resource.metadata["name"].decode("utf-8")
)
save_dir = os.path.join(self.storage_dir, "rncp_received")
os.makedirs(save_dir, exist_ok=True)
@@ -105,13 +117,17 @@ class RNCPHandler:
while os.path.isfile(saved_filename):
counter += 1
base, ext = os.path.splitext(filename)
saved_filename = os.path.join(save_dir, f"{base}.{counter}{ext}")
saved_filename = os.path.join(
save_dir, f"{base}.{counter}{ext}"
)
shutil.move(resource.data.name, saved_filename)
if transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "completed"
self.active_transfers[transfer_id]["saved_path"] = saved_filename
self.active_transfers[transfer_id]["saved_path"] = (
saved_filename
)
self.active_transfers[transfer_id]["filename"] = filename
except Exception as e:
if transfer_id in self.active_transfers:
@@ -120,7 +136,9 @@ class RNCPHandler:
elif transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "failed"
def _fetch_request(self, path, data, request_id, link_id, remote_identity, requested_at):
def _fetch_request(
self, path, data, request_id, link_id, remote_identity, requested_at
):
if self.fetch_jail:
if data.startswith(self.fetch_jail + "/"):
data = data.replace(self.fetch_jail + "/", "")
@@ -171,7 +189,9 @@ class RNCPHandler:
RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
):
await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash):
@@ -257,7 +277,9 @@ class RNCPHandler:
RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
):
await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash):
@@ -326,7 +348,9 @@ class RNCPHandler:
if resource.status == RNS.Resource.COMPLETE:
if resource.metadata:
try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
filename = os.path.basename(
resource.metadata["name"].decode("utf-8")
)
if save_path:
save_dir = os.path.abspath(os.path.expanduser(save_path))
os.makedirs(save_dir, exist_ok=True)
@@ -367,7 +391,12 @@ class RNCPHandler:
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
link.set_resource_started_callback(fetch_resource_started)
link.set_resource_concluded_callback(fetch_resource_concluded)
link.request("fetch_file", data=file_path, response_callback=request_response, failed_callback=request_failed)
link.request(
"fetch_file",
data=file_path,
response_callback=request_response,
failed_callback=request_failed,
)
while not request_resolved:
await asyncio.sleep(0.1)
@@ -418,4 +447,3 @@ class RNCPHandler:
"error": transfer.get("error"),
}
return None

View File

@@ -31,8 +31,14 @@ class RNProbeHandler:
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash))
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
timeout_after = time.time() + (
timeout
or self.DEFAULT_TIMEOUT
+ self.reticulum.get_first_hop_timeout(destination_hash)
)
while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
):
await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash):
@@ -70,8 +76,14 @@ class RNProbeHandler:
if_name = self.reticulum.get_next_hop_if_name(destination_hash)
if_str = f" on {if_name}" if if_name and if_name != "None" else ""
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash))
while receipt.status == RNS.PacketReceipt.SENT and time.time() < timeout_after:
timeout_after = time.time() + (
timeout
or self.DEFAULT_TIMEOUT
+ self.reticulum.get_first_hop_timeout(destination_hash)
)
while (
receipt.status == RNS.PacketReceipt.SENT and time.time() < timeout_after
):
await asyncio.sleep(0.1)
result: dict = {
@@ -96,9 +108,15 @@ class RNProbeHandler:
reception_stats = {}
if self.reticulum.is_connected_to_shared_instance:
reception_rssi = self.reticulum.get_packet_rssi(receipt.proof_packet.packet_hash)
reception_snr = self.reticulum.get_packet_snr(receipt.proof_packet.packet_hash)
reception_q = self.reticulum.get_packet_q(receipt.proof_packet.packet_hash)
reception_rssi = self.reticulum.get_packet_rssi(
receipt.proof_packet.packet_hash
)
reception_snr = self.reticulum.get_packet_snr(
receipt.proof_packet.packet_hash
)
reception_q = self.reticulum.get_packet_q(
receipt.proof_packet.packet_hash
)
if reception_rssi is not None:
reception_stats["rssi"] = reception_rssi
@@ -134,4 +152,3 @@ class RNProbeHandler:
"timeouts": sum(1 for r in results if r["status"] == "timeout"),
"failed": sum(1 for r in results if r["status"] == "failed"),
}

View File

@@ -25,7 +25,12 @@ class RNStatusHandler:
def __init__(self, reticulum_instance):
self.reticulum = reticulum_instance
def get_status(self, include_link_stats: bool = False, sorting: str | None = None, sort_reverse: bool = False):
def get_status(
self,
include_link_stats: bool = False,
sorting: str | None = None,
sort_reverse: bool = False,
):
stats = None
link_count = None
@@ -53,15 +58,25 @@ class RNStatusHandler:
if sorting and isinstance(sorting, str):
sorting = sorting.lower()
if sorting in ("rate", "bitrate"):
interfaces.sort(key=lambda i: i.get("bitrate", 0) or 0, reverse=sort_reverse)
interfaces.sort(
key=lambda i: i.get("bitrate", 0) or 0, reverse=sort_reverse
)
elif sorting == "rx":
interfaces.sort(key=lambda i: i.get("rxb", 0) or 0, reverse=sort_reverse)
interfaces.sort(
key=lambda i: i.get("rxb", 0) or 0, reverse=sort_reverse
)
elif sorting == "tx":
interfaces.sort(key=lambda i: i.get("txb", 0) or 0, reverse=sort_reverse)
interfaces.sort(
key=lambda i: i.get("txb", 0) or 0, reverse=sort_reverse
)
elif sorting == "rxs":
interfaces.sort(key=lambda i: i.get("rxs", 0) or 0, reverse=sort_reverse)
interfaces.sort(
key=lambda i: i.get("rxs", 0) or 0, reverse=sort_reverse
)
elif sorting == "txs":
interfaces.sort(key=lambda i: i.get("txs", 0) or 0, reverse=sort_reverse)
interfaces.sort(
key=lambda i: i.get("txs", 0) or 0, reverse=sort_reverse
)
elif sorting == "traffic":
interfaces.sort(
key=lambda i: (i.get("rxb", 0) or 0) + (i.get("txb", 0) or 0),
@@ -84,13 +99,19 @@ class RNStatusHandler:
reverse=sort_reverse,
)
elif sorting == "held":
interfaces.sort(key=lambda i: i.get("held_announces", 0) or 0, reverse=sort_reverse)
interfaces.sort(
key=lambda i: i.get("held_announces", 0) or 0, reverse=sort_reverse
)
formatted_interfaces = []
for ifstat in interfaces:
name = ifstat.get("name", "")
if name.startswith("LocalInterface[") or name.startswith("TCPInterface[Client") or name.startswith("BackboneInterface[Client on"):
if (
name.startswith("LocalInterface[")
or name.startswith("TCPInterface[Client")
or name.startswith("BackboneInterface[Client on")
):
continue
formatted_if: dict[str, Any] = {
@@ -165,9 +186,13 @@ class RNStatusHandler:
formatted_if["peers"] = ifstat["peers"]
if "incoming_announce_frequency" in ifstat:
formatted_if["incoming_announce_frequency"] = ifstat["incoming_announce_frequency"]
formatted_if["incoming_announce_frequency"] = ifstat[
"incoming_announce_frequency"
]
if "outgoing_announce_frequency" in ifstat:
formatted_if["outgoing_announce_frequency"] = ifstat["outgoing_announce_frequency"]
formatted_if["outgoing_announce_frequency"] = ifstat[
"outgoing_announce_frequency"
]
if "held_announces" in ifstat:
formatted_if["held_announces"] = ifstat["held_announces"]
@@ -181,4 +206,3 @@ class RNStatusHandler:
"link_count": link_count,
"timestamp": time.time(),
}

View File

@@ -76,7 +76,9 @@ class TelephoneManager:
destination_identity = RNS.Identity.recall(destination_hash)
if destination_identity is None:
# If not found by identity hash, try as destination hash
destination_identity = RNS.Identity.recall(destination_hash) # Identity.recall takes identity hash
destination_identity = RNS.Identity.recall(
destination_hash
) # Identity.recall takes identity hash
if destination_identity is None:
msg = "Destination identity not found"
@@ -92,4 +94,3 @@ class TelephoneManager:
self.call_is_incoming = False
await asyncio.to_thread(self.telephone.call, destination_identity)
return self.telephone.active_call

View File

@@ -6,12 +6,14 @@ from typing import Any
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
try:
from argostranslate import package, translate
HAS_ARGOS_LIB = True
except ImportError:
HAS_ARGOS_LIB = False
@@ -63,7 +65,9 @@ LANGUAGE_CODE_TO_NAME = {
class TranslatorHandler:
def __init__(self, libretranslate_url: str | None = None):
self.libretranslate_url = libretranslate_url or os.getenv("LIBRETRANSLATE_URL", "http://localhost:5000")
self.libretranslate_url = libretranslate_url or os.getenv(
"LIBRETRANSLATE_URL", "http://localhost:5000"
)
self.has_argos = HAS_ARGOS
self.has_argos_lib = HAS_ARGOS_LIB
self.has_argos_cli = HAS_ARGOS_CLI
@@ -136,7 +140,12 @@ class TranslatorHandler:
if self.has_requests:
try:
url = libretranslate_url or self.libretranslate_url
return self._translate_libretranslate(text, source_lang=source_lang, target_lang=target_lang, libretranslate_url=url)
return self._translate_libretranslate(
text,
source_lang=source_lang,
target_lang=target_lang,
libretranslate_url=url,
)
except Exception as e:
if self.has_argos:
return self._translate_argos(text, source_lang, target_lang)
@@ -148,7 +157,13 @@ class TranslatorHandler:
msg = "No translation backend available. Install requests for LibreTranslate or argostranslate for local translation."
raise RuntimeError(msg)
def _translate_libretranslate(self, text: str, source_lang: str, target_lang: str, libretranslate_url: str | None = None) -> dict[str, Any]:
def _translate_libretranslate(
self,
text: str,
source_lang: str,
target_lang: str,
libretranslate_url: str | None = None,
) -> dict[str, Any]:
if not self.has_requests:
msg = "requests library not available"
raise RuntimeError(msg)
@@ -172,12 +187,16 @@ class TranslatorHandler:
result = response.json()
return {
"translated_text": result.get("translatedText", ""),
"source_lang": result.get("detectedLanguage", {}).get("language", source_lang),
"source_lang": result.get("detectedLanguage", {}).get(
"language", source_lang
),
"target_lang": target_lang,
"source": "libretranslate",
}
def _translate_argos(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
def _translate_argos(
self, text: str, source_lang: str, target_lang: str
) -> dict[str, Any]:
if source_lang == "auto":
if self.has_argos_lib:
detected_lang = self._detect_language(text)
@@ -200,7 +219,9 @@ class TranslatorHandler:
msg = "Argos Translate not available (neither library nor CLI)"
raise RuntimeError(msg)
def _translate_argos_lib(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
def _translate_argos_lib(
self, text: str, source_lang: str, target_lang: str
) -> dict[str, Any]:
try:
installed_packages = package.get_installed_packages()
translation_package = None
@@ -228,7 +249,9 @@ class TranslatorHandler:
msg = f"Argos Translate error: {e}"
raise RuntimeError(msg)
def _translate_argos_cli(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
def _translate_argos_cli(
self, text: str, source_lang: str, target_lang: str
) -> dict[str, Any]:
if source_lang == "auto" or not source_lang:
msg = "Auto-detection is not supported with CLI. Please select a source language manually."
raise ValueError(msg)
@@ -251,7 +274,14 @@ class TranslatorHandler:
raise RuntimeError(msg)
try:
args = [executable, "--from-lang", source_lang, "--to-lang", target_lang, text]
args = [
executable,
"--from-lang",
source_lang,
"--to-lang",
target_lang,
text,
]
result = subprocess.run(args, capture_output=True, text=True, check=True) # noqa: S603
translated_text = result.stdout.strip()
if not translated_text:
@@ -264,7 +294,11 @@ class TranslatorHandler:
"source": "argos",
}
except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or str(e))
error_msg = (
e.stderr.decode()
if isinstance(e.stderr, bytes)
else (e.stderr or str(e))
)
msg = f"Argos Translate CLI error: {error_msg}"
raise RuntimeError(msg)
except Exception as e:
@@ -333,7 +367,9 @@ class TranslatorHandler:
return languages
def install_language_package(self, package_name: str = "translate") -> dict[str, Any]:
def install_language_package(
self, package_name: str = "translate"
) -> dict[str, Any]:
argospm = shutil.which("argospm")
if not argospm:
msg = "argospm not found in PATH. Install argostranslate first."

View File

@@ -0,0 +1,301 @@
import os
import platform
import shutil
import subprocess
import threading
import time
import LXST
import RNS
from LXST.Codecs import Null
from LXST.Pipeline import Pipeline
from LXST.Sinks import OpusFileSink
from LXST.Sources import OpusFileSource
class VoicemailManager:
def __init__(self, db, telephone_manager, storage_dir):
self.db = db
self.telephone_manager = telephone_manager
self.storage_dir = os.path.join(storage_dir, "voicemails")
self.greetings_dir = os.path.join(self.storage_dir, "greetings")
self.recordings_dir = os.path.join(self.storage_dir, "recordings")
# Ensure directories exist
os.makedirs(self.greetings_dir, exist_ok=True)
os.makedirs(self.recordings_dir, exist_ok=True)
self.is_recording = False
self.recording_pipeline = None
self.recording_sink = None
self.recording_start_time = None
self.recording_remote_identity = None
self.recording_filename = None
# Paths to executables
self.espeak_path = self._find_espeak()
self.ffmpeg_path = self._find_ffmpeg()
# Check for presence
self.has_espeak = self.espeak_path is not None
self.has_ffmpeg = self.ffmpeg_path is not None
if self.has_espeak:
RNS.log(f"Voicemail: Found eSpeak at {self.espeak_path}", RNS.LOG_DEBUG)
else:
RNS.log("Voicemail: eSpeak not found", RNS.LOG_ERROR)
if self.has_ffmpeg:
RNS.log(f"Voicemail: Found ffmpeg at {self.ffmpeg_path}", RNS.LOG_DEBUG)
else:
RNS.log("Voicemail: ffmpeg not found", RNS.LOG_ERROR)
def _find_espeak(self):
# Try standard name first
path = shutil.which("espeak-ng")
if path:
return path
# Try without -ng suffix
path = shutil.which("espeak")
if path:
return path
# Windows common install locations if not in PATH
if platform.system() == "Windows":
common_paths = [
os.path.expandvars(r"%ProgramFiles%\eSpeak NG\espeak-ng.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\eSpeak NG\espeak-ng.exe"),
os.path.expandvars(r"%ProgramFiles%\eSpeak\espeak.exe"),
]
for p in common_paths:
if os.path.exists(p):
return p
return None
def _find_ffmpeg(self):
path = shutil.which("ffmpeg")
if path:
return path
# Windows common install locations
if platform.system() == "Windows":
common_paths = [
os.path.expandvars(r"%ProgramFiles%\ffmpeg\bin\ffmpeg.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\ffmpeg\bin\ffmpeg.exe"),
]
for p in common_paths:
if os.path.exists(p):
return p
return None
def generate_greeting(self, text):
if not self.has_espeak or not self.has_ffmpeg:
msg = "espeak-ng and ffmpeg are required for greeting generation"
raise RuntimeError(msg)
wav_path = os.path.join(self.greetings_dir, "greeting.wav")
opus_path = os.path.join(self.greetings_dir, "greeting.opus")
try:
# espeak-ng to WAV
subprocess.run([self.espeak_path, "-w", wav_path, text], check=True)
# ffmpeg to Opus
if os.path.exists(opus_path):
os.remove(opus_path)
subprocess.run(
[
self.ffmpeg_path,
"-i",
wav_path,
"-c:a",
"libopus",
"-b:a",
"16k",
"-vbr",
"on",
opus_path,
],
check=True,
)
return opus_path
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
def handle_incoming_call(self, caller_identity):
if not self.db.config.voicemail_enabled.get():
return
delay = self.db.config.voicemail_auto_answer_delay_seconds.get()
def voicemail_job():
time.sleep(delay)
# Check if still ringing and no other active call
telephone = self.telephone_manager.telephone
if (
telephone
and telephone.active_call
and telephone.active_call.get_remote_identity() == caller_identity
and telephone.call_status == LXST.Signalling.STATUS_RINGING
):
RNS.log(
f"Auto-answering call from {RNS.prettyhexrep(caller_identity.hash)} for voicemail",
RNS.LOG_DEBUG,
)
self.start_voicemail_session(caller_identity)
threading.Thread(target=voicemail_job, daemon=True).start()
def start_voicemail_session(self, caller_identity):
telephone = self.telephone_manager.telephone
if not telephone:
return
# Answer the call
if not telephone.answer(caller_identity):
return
# Stop microphone if it's active to prevent local noise being sent or recorded
if telephone.audio_input:
telephone.audio_input.stop()
# Play greeting
greeting_path = os.path.join(self.greetings_dir, "greeting.opus")
if not os.path.exists(greeting_path):
# Fallback if no greeting generated yet
self.generate_greeting(self.db.config.voicemail_greeting.get())
def session_job():
try:
# 1. Play greeting
greeting_source = OpusFileSource(greeting_path, target_frame_ms=60)
# Attach to transmit mixer
greeting_pipeline = Pipeline(
source=greeting_source, codec=Null(), sink=telephone.transmit_mixer
)
greeting_pipeline.start()
# Wait for greeting to finish
while greeting_source.running:
time.sleep(0.1)
if not telephone.active_call:
return
greeting_pipeline.stop()
# 2. Play beep
beep_source = LXST.ToneSource(
frequency=800,
gain=0.1,
target_frame_ms=60,
codec=Null(),
sink=telephone.transmit_mixer,
)
beep_source.start()
time.sleep(0.5)
beep_source.stop()
# 3. Start recording
self.start_recording(caller_identity)
# 4. Wait for max recording time or hangup
max_time = self.db.config.voicemail_max_recording_seconds.get()
start_wait = time.time()
while self.is_recording and (time.time() - start_wait < max_time):
time.sleep(0.5)
if not telephone.active_call:
break
# 5. End session
if telephone.active_call:
telephone.hangup()
self.stop_recording()
except Exception as e:
RNS.log(f"Error during voicemail session: {e}", RNS.LOG_ERROR)
if self.is_recording:
self.stop_recording()
threading.Thread(target=session_job, daemon=True).start()
def start_recording(self, caller_identity):
telephone = self.telephone_manager.telephone
if not telephone or not telephone.active_call:
return
timestamp = time.time()
filename = f"voicemail_{caller_identity.hash.hex()}_{int(timestamp)}.opus"
filepath = os.path.join(self.recordings_dir, filename)
try:
self.recording_sink = OpusFileSink(filepath)
# Connect the caller's audio source to our sink
# active_call.audio_source is a LinkSource that feeds into receive_mixer
# We want to record what we receive.
self.recording_pipeline = Pipeline(
source=telephone.active_call.audio_source,
codec=Null(),
sink=self.recording_sink,
)
self.recording_pipeline.start()
self.is_recording = True
self.recording_start_time = timestamp
self.recording_remote_identity = caller_identity
self.recording_filename = filename
RNS.log(
f"Started recording voicemail from {RNS.prettyhexrep(caller_identity.hash)}",
RNS.LOG_DEBUG,
)
except Exception as e:
RNS.log(f"Failed to start recording: {e}", RNS.LOG_ERROR)
def stop_recording(self):
if not self.is_recording:
return
try:
duration = int(time.time() - self.recording_start_time)
self.recording_pipeline.stop()
self.recording_sink = None
self.recording_pipeline = None
# Save to database if long enough
if duration >= 1:
remote_name = self.telephone_manager.get_name_for_identity_hash(
self.recording_remote_identity.hash.hex()
)
self.db.voicemails.add_voicemail(
remote_identity_hash=self.recording_remote_identity.hash.hex(),
remote_identity_name=remote_name,
filename=self.recording_filename,
duration_seconds=duration,
timestamp=self.recording_start_time,
)
RNS.log(
f"Saved voicemail from {RNS.prettyhexrep(self.recording_remote_identity.hash)} ({duration}s)",
RNS.LOG_DEBUG,
)
else:
# Delete short/empty recording
filepath = os.path.join(self.recordings_dir, self.recording_filename)
if os.path.exists(filepath):
os.remove(filepath)
self.is_recording = False
self.recording_start_time = None
self.recording_remote_identity = None
self.recording_filename = None
except Exception as e:
RNS.log(f"Error stopping recording: {e}", RNS.LOG_ERROR)
self.is_recording = False

View File

@@ -26,7 +26,7 @@
<div
class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner"
>
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo-chat-bubble.png" />
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo.png" />
</div>
<div class="my-auto">
<div
@@ -387,11 +387,7 @@
</div>
</template>
</template>
<CallOverlay
v-if="activeCall || isCallEnded"
:active-call="activeCall || lastCall"
:is-ended="isCallEnded"
/>
<CallOverlay v-if="activeCall || isCallEnded" :active-call="activeCall || lastCall" :is-ended="isCallEnded" />
<Toast />
</div>
</template>

View File

@@ -5,7 +5,7 @@
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
>
<div class="text-center mb-8">
<img class="w-16 h-16 mx-auto mb-4" src="/assets/images/logo-chat-bubble.png" />
<img class="w-16 h-16 mx-auto mb-4" src="/assets/images/logo.png" />
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
{{ isSetup ? "Initial Setup" : "Authentication Required" }}
</h1>

View File

@@ -7,12 +7,17 @@
<!-- Header -->
<div class="p-3 flex items-center bg-gray-50 dark:bg-zinc-800/50 border-b border-gray-100 dark:border-zinc-800">
<div class="flex-1 flex items-center space-x-2">
<div
class="size-2 rounded-full"
:class="isEnded ? 'bg-red-500' : 'bg-green-500 animate-pulse'"
></div>
<div class="size-2 rounded-full" :class="isEnded ? 'bg-red-500' : 'bg-green-500 animate-pulse'"></div>
<span class="text-[10px] font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
{{ isEnded ? "Call Ended" : (activeCall.status === 6 ? "Active Call" : "Call Status") }}
{{
isEnded
? "Call Ended"
: activeCall.is_voicemail
? "Recording Voicemail"
: activeCall.status === 6
? "Active Call"
: "Call Status"
}}
</span>
</div>
<button
@@ -31,12 +36,12 @@
<div v-show="!isMinimized" class="p-4">
<!-- icon and name -->
<div class="flex flex-col items-center mb-4">
<div
<div
class="p-4 rounded-full mb-3"
:class="isEnded ? 'bg-red-100 dark:bg-red-900/30' : 'bg-blue-100 dark:bg-blue-900/30'"
>
<MaterialDesignIcon
icon-name="account"
<MaterialDesignIcon
icon-name="account"
class="size-8"
:class="isEnded ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'"
/>
@@ -60,10 +65,11 @@
<div
class="text-sm font-medium"
:class="[
isEnded ? 'text-red-600 dark:text-red-400 animate-pulse' :
(activeCall.status === 6
? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-zinc-400')
isEnded
? 'text-red-600 dark:text-red-400 animate-pulse'
: activeCall.status === 6
? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-zinc-400',
]"
>
<span v-if="isEnded">Call Ended</span>
@@ -150,7 +156,10 @@
</div>
<!-- Minimized State -->
<div v-show="isMinimized && !isEnded" class="px-4 py-2 flex items-center justify-between bg-white dark:bg-zinc-900">
<div
v-show="isMinimized && !isEnded"
class="px-4 py-2 flex items-center justify-between bg-white dark:bg-zinc-900"
>
<div class="flex items-center space-x-2 overflow-hidden mr-2">
<MaterialDesignIcon icon-name="account" class="size-5 text-blue-500" />
<span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate">

View File

@@ -1,183 +1,230 @@
<template>
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{ dark: config?.theme === 'dark' }">
<div class="mx-auto my-auto w-full max-w-xl p-4">
<div v-if="activeCall || isCallEnded" class="flex">
<div class="mx-auto my-auto min-w-64">
<div class="text-center">
<div>
<!-- icon -->
<div class="flex mb-4">
<div
class="mx-auto bg-gray-300 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-4 rounded-full"
:class="{ 'animate-pulse': activeCall && activeCall.status === 4 }"
>
<MaterialDesignIcon icon-name="account" class="size-12" />
</div>
</div>
<div class="flex flex-col w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{ dark: config?.theme === 'dark' }">
<div class="mx-auto w-full max-w-xl p-4 flex-1 flex flex-col">
<!-- Tabs -->
<div class="flex border-b border-gray-200 dark:border-zinc-800 mb-6 shrink-0">
<button
:class="[
activeTab === 'phone'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
]"
class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
@click="activeTab = 'phone'"
>
Phone
</button>
<button
:class="[
activeTab === 'voicemail'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
]"
class="py-2 px-4 border-b-2 font-medium text-sm flex items-center gap-2 transition-all"
@click="activeTab = 'voicemail'"
>
Voicemail
<span
v-if="unreadVoicemailsCount > 0"
class="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full animate-pulse"
>{{ unreadVoicemailsCount }}</span
>
</button>
<button
:class="[
activeTab === 'settings'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
]"
class="py-2 px-4 border-b-2 font-medium text-sm ml-auto transition-all"
@click="activeTab = 'settings'"
>
<MaterialDesignIcon icon-name="cog" class="size-4" />
</button>
</div>
<!-- name -->
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">
<span v-if="(activeCall || lastCall)?.remote_identity_name != null">{{
(activeCall || lastCall).remote_identity_name
}}</span>
<span v-else>Unknown</span>
</div>
<!-- identity hash -->
<div
v-if="(activeCall || lastCall)?.remote_identity_hash != null"
class="text-gray-500 dark:text-zinc-100 opacity-60 text-sm"
>
{{
(activeCall || lastCall).remote_identity_hash
? formatDestinationHash((activeCall || lastCall).remote_identity_hash)
: ""
}}
</div>
</div>
<!-- call status -->
<div class="text-gray-500 dark:text-zinc-100 mb-4 mt-2">
<template v-if="isCallEnded">
<span class="text-red-500 font-bold animate-pulse">Call Ended</span>
</template>
<template v-else-if="activeCall">
<span v-if="activeCall.is_incoming && activeCall.status === 4" class="animate-bounce inline-block">Incoming Call...</span>
<span v-else>
<span v-if="activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall.status === 1">Rejected...</span>
<span v-else-if="activeCall.status === 2">Calling...</span>
<span v-else-if="activeCall.status === 3">Available...</span>
<span v-else-if="activeCall.status === 4">Ringing...</span>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall.status === 6" class="text-green-500 font-medium">Connected</span>
<span v-else>Status: {{ activeCall.status }}</span>
</span>
</template>
</div>
<!-- settings during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mb-4">
<div class="w-full">
<select
v-model="selectedAudioProfileId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white dark:focus:ring-blue-600 dark:focus:border-blue-600"
@change="switchAudioProfile(selectedAudioProfileId)"
>
<option
v-for="audioProfile in audioProfiles"
:key="audioProfile.id"
:value="audioProfile.id"
<!-- Phone Tab -->
<div v-if="activeTab === 'phone'" class="flex-1 flex flex-col">
<div v-if="activeCall || isCallEnded" class="flex my-auto">
<div class="mx-auto my-auto min-w-64">
<div class="text-center">
<div>
<!-- icon -->
<div class="flex mb-4">
<div
class="mx-auto bg-gray-300 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-4 rounded-full"
:class="{ 'animate-pulse': activeCall && activeCall.status === 4 }"
>
{{ audioProfile.name }}
</option>
</select>
</div>
</div>
<!-- controls during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mx-auto space-x-4 mb-8">
<!-- mute/unmute mic -->
<button
type="button"
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
:class="[
isMicMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleMicrophone"
>
<MaterialDesignIcon
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
class="size-8"
/>
</button>
<!-- mute/unmute speaker -->
<button
type="button"
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
:class="[
isSpeakerMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleSpeaker"
>
<MaterialDesignIcon
:icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'"
class="size-8"
/>
</button>
<!-- toggle stats -->
<button
type="button"
:class="[
isShowingStats
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="isShowingStats = !isShowingStats"
>
<MaterialDesignIcon icon-name="chart-bar" class="size-8" />
</button>
</div>
<!-- actions -->
<div v-if="activeCall" class="mx-auto space-x-4">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
title="Answer Call"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
<span>Accept</span>
</button>
<!-- hangup/decline call -->
<button
:title="
activeCall.is_incoming && activeCall.status === 4 ? 'Decline Call' : 'Hangup Call'
"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4 ? "Decline" : "Hangup"
}}</span>
</button>
</div>
<!-- stats -->
<div
v-if="isShowingStats"
class="mt-4 p-4 text-left bg-gray-200 dark:bg-zinc-800 rounded-lg text-sm text-gray-600 dark:text-zinc-300"
>
<div class="grid grid-cols-2 gap-2">
<div>
TX: {{ activeCall.tx_packets }} ({{ formatBytes(activeCall.tx_bytes) }})
<MaterialDesignIcon icon-name="account" class="size-12" />
</div>
</div>
<div>
RX: {{ activeCall.rx_packets }} ({{ formatBytes(activeCall.rx_bytes) }})
<!-- name -->
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">
<span v-if="(activeCall || lastCall)?.remote_identity_name != null">{{
(activeCall || lastCall).remote_identity_name
}}</span>
<span v-else>Unknown</span>
</div>
<!-- identity hash -->
<div
v-if="(activeCall || lastCall)?.remote_identity_hash != null"
class="text-gray-500 dark:text-zinc-100 opacity-60 text-sm"
>
{{
(activeCall || lastCall).remote_identity_hash
? formatDestinationHash((activeCall || lastCall).remote_identity_hash)
: ""
}}
</div>
</div>
<!-- call status -->
<div class="text-gray-500 dark:text-zinc-100 mb-4 mt-2">
<template v-if="isCallEnded">
<span class="text-red-500 font-bold animate-pulse">Call Ended</span>
</template>
<template v-else-if="activeCall">
<span
v-if="activeCall.is_incoming && activeCall.status === 4"
class="animate-bounce inline-block"
>Incoming Call...</span
>
<span v-else>
<span v-if="activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall.status === 1">Rejected...</span>
<span v-else-if="activeCall.status === 2">Calling...</span>
<span v-else-if="activeCall.status === 3">Available...</span>
<span v-else-if="activeCall.status === 4">Ringing...</span>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall.status === 6" class="text-green-500 font-medium"
>Connected</span
>
<span v-else>Status: {{ activeCall.status }}</span>
</span>
</template>
</div>
<!-- settings during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mb-4">
<div class="w-full">
<select
v-model="selectedAudioProfileId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white dark:focus:ring-blue-600 dark:focus:border-blue-600"
@change="switchAudioProfile(selectedAudioProfileId)"
>
<option
v-for="audioProfile in audioProfiles"
:key="audioProfile.id"
:value="audioProfile.id"
>
{{ audioProfile.name }}
</option>
</select>
</div>
</div>
<!-- controls during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mx-auto space-x-4 mb-8">
<!-- mute/unmute mic -->
<button
type="button"
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
:class="[
isMicMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleMicrophone"
>
<MaterialDesignIcon
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
class="size-8"
/>
</button>
<!-- mute/unmute speaker -->
<button
type="button"
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
:class="[
isSpeakerMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleSpeaker"
>
<MaterialDesignIcon
:icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'"
class="size-8"
/>
</button>
<!-- toggle stats -->
<button
type="button"
:class="[
isShowingStats
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="isShowingStats = !isShowingStats"
>
<MaterialDesignIcon icon-name="chart-bar" class="size-8" />
</button>
</div>
<!-- actions -->
<div v-if="activeCall" class="mx-auto space-x-4">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
title="Answer Call"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
<span>Accept</span>
</button>
<!-- hangup/decline call -->
<button
:title="
activeCall.is_incoming && activeCall.status === 4
? 'Decline Call'
: 'Hangup Call'
"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4 ? "Decline" : "Hangup"
}}</span>
</button>
</div>
<!-- stats -->
<div
v-if="isShowingStats"
class="mt-4 p-4 text-left bg-gray-200 dark:bg-zinc-800 rounded-lg text-sm text-gray-600 dark:text-zinc-300"
>
<div class="grid grid-cols-2 gap-2">
<div>TX: {{ activeCall.tx_packets }} ({{ formatBytes(activeCall.tx_bytes) }})</div>
<div>RX: {{ activeCall.rx_packets }} ({{ formatBytes(activeCall.rx_bytes) }})</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex">
<div class="mx-auto my-auto w-full">
<div v-else class="my-auto">
<div class="text-center mb-4">
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">Telephone</div>
<div class="text-gray-500 dark:text-zinc-400">Enter an identity hash to call.</div>
@@ -200,72 +247,304 @@
</button>
</div>
</div>
<!-- Call History -->
<div v-if="callHistory.length > 0 && !activeCall && !isCallEnded" class="mt-8">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
>
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
Call History
</h3>
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
</div>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="entry in callHistory"
:key="entry.id"
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
>
<div class="flex items-center space-x-3">
<div :class="entry.is_incoming ? 'text-blue-500' : 'text-green-500'">
<MaterialDesignIcon
:icon-name="entry.is_incoming ? 'phone-incoming' : 'phone-outgoing'"
class="size-5"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{{ entry.remote_identity_name || "Unknown" }}
</p>
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2">
{{ entry.timestamp ? formatDateTime(entry.timestamp * 1000) : "" }}
</span>
</div>
<div class="flex items-center justify-between mt-0.5">
<div
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2"
>
<span>{{ entry.status }}</span>
<span v-if="entry.duration_seconds > 0"
> {{ formatDuration(entry.duration_seconds) }}</span
>
</div>
<button
type="button"
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter"
@click="
destinationHash = entry.remote_identity_hash;
call(destinationHash);
"
>
Call Back
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<div v-if="callHistory.length > 0 && !activeCall" class="mt-8">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
>
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
Call History
</h3>
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
<!-- Voicemail Tab -->
<div v-if="activeTab === 'voicemail'" class="flex-1 flex flex-col">
<div v-if="voicemails.length === 0" class="my-auto text-center">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
<MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
</div>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="entry in callHistory"
:key="entry.id"
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No Voicemails</h3>
<p class="text-gray-500 dark:text-zinc-400">
When people leave you messages, they'll show up here.
</p>
</div>
<div v-else class="space-y-4">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
>
<div class="flex items-center space-x-3">
<div :class="entry.is_incoming ? 'text-blue-500' : 'text-green-500'">
<MaterialDesignIcon
:icon-name="entry.is_incoming ? 'phone-incoming' : 'phone-outgoing'"
class="size-5"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{{ entry.remote_identity_name || "Unknown" }}
</p>
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2">
{{
entry.timestamp
? formatDateTime(
entry.timestamp * 1000
)
: ""
}}
</span>
</div>
<div class="flex items-center justify-between mt-0.5">
<div
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2"
>
<span>{{ entry.status }}</span>
<span v-if="entry.duration_seconds > 0"
> {{ formatDuration(entry.duration_seconds) }}</span
>
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
Voicemail Inbox
</h3>
<span
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-2 py-0.5 rounded-full font-bold uppercase"
>
{{ voicemails.length }} Messages
</span>
</div>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="voicemail in voicemails"
:key="voicemail.id"
class="px-4 py-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
:class="{ 'bg-blue-50/50 dark:bg-blue-900/10': !voicemail.is_read }"
>
<div class="flex items-start space-x-4">
<!-- Play/Pause Button -->
<button
class="shrink-0 size-10 rounded-full flex items-center justify-center transition-all"
:class="
playingVoicemailId === voicemail.id
? 'bg-red-500 text-white animate-pulse'
: 'bg-blue-500 text-white hover:bg-blue-600'
"
@click="playVoicemail(voicemail)"
>
<MaterialDesignIcon
:icon-name="playingVoicemailId === voicemail.id ? 'stop' : 'play'"
class="size-6"
/>
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-bold text-gray-900 dark:text-white truncate">
{{ voicemail.remote_identity_name || "Unknown" }}
<span
v-if="!voicemail.is_read"
class="ml-2 size-2 inline-block rounded-full bg-blue-500"
></span>
</p>
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono">
{{ formatDateTime(voicemail.timestamp * 1000) }}
</span>
</div>
<button
type="button"
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter"
@click="
destinationHash = entry.remote_identity_hash;
call(destinationHash);
"
<div
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-3 mb-3"
>
Call Back
</button>
<span class="flex items-center gap-1">
<MaterialDesignIcon icon-name="clock-outline" class="size-3" />
{{ formatDuration(voicemail.duration_seconds) }}
</span>
<span class="opacity-60 font-mono text-[10px]">{{
formatDestinationHash(voicemail.remote_identity_hash)
}}</span>
</div>
<div class="flex items-center gap-4">
<button
type="button"
class="text-[10px] flex items-center gap-1 text-blue-500 hover:text-blue-600 font-bold uppercase tracking-wider transition-colors"
@click="
destinationHash = voicemail.remote_identity_hash;
activeTab = 'phone';
call(destinationHash);
"
>
<MaterialDesignIcon icon-name="phone" class="size-3" />
Call Back
</button>
<button
type="button"
class="text-[10px] flex items-center gap-1 text-red-500 hover:text-red-600 font-bold uppercase tracking-wider transition-colors"
@click="deleteVoicemail(voicemail.id)"
>
<MaterialDesignIcon icon-name="delete" class="size-3" />
Delete
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Settings Tab -->
<div v-if="activeTab === 'settings' && config" class="flex-1 space-y-6">
<div
class="bg-white dark:bg-zinc-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-zinc-800"
>
<h3
class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-6 flex items-center gap-2"
>
<MaterialDesignIcon icon-name="voicemail" class="size-5 text-blue-500" />
Voicemail Settings
</h3>
<!-- Status Banner -->
<div
v-if="!voicemailStatus.has_espeak || !voicemailStatus.has_ffmpeg"
class="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg flex gap-3 items-start"
>
<MaterialDesignIcon
icon-name="alert"
class="size-5 text-amber-600 dark:text-amber-400 shrink-0"
/>
<div class="text-xs text-amber-800 dark:text-amber-200">
<p class="font-bold mb-1">Dependencies Missing</p>
<p v-if="!voicemailStatus.has_espeak">
Voicemail requires `espeak-ng` to generate greetings. Please install it on your system.
</p>
<p v-if="!voicemailStatus.has_ffmpeg" :class="{ 'mt-1': !voicemailStatus.has_espeak }">
Voicemail requires `ffmpeg` to process audio files. Please install it on your system.
</p>
</div>
</div>
<div class="space-y-6">
<!-- Enabled Toggle -->
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">Enable Voicemail</div>
<div class="text-xs text-gray-500 dark:text-zinc-400">
Accept calls automatically and record messages
</div>
</div>
</li>
</ul>
<button
:disabled="!voicemailStatus.has_espeak || !voicemailStatus.has_ffmpeg"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
:class="config.voicemail_enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-zinc-700'"
@click="
config.voicemail_enabled = !config.voicemail_enabled;
updateConfig({ voicemail_enabled: config.voicemail_enabled });
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="config.voicemail_enabled ? 'translate-x-5' : 'translate-x-0'"
></span>
</button>
</div>
<!-- Greeting Text -->
<div class="space-y-2">
<label class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
>Greeting Message</label
>
<textarea
v-model="config.voicemail_greeting"
rows="3"
class="block w-full rounded-lg border-0 py-2 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-zinc-900"
placeholder="Enter greeting text..."
></textarea>
<div class="flex justify-between items-center">
<p class="text-[10px] text-gray-500 dark:text-zinc-500">
This text will be converted to speech using eSpeak NG.
</p>
<button
:disabled="!voicemailStatus.has_espeak || isGeneratingGreeting"
class="text-[10px] bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-3 py-1 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50"
@click="
updateConfig({ voicemail_greeting: config.voicemail_greeting });
generateGreeting();
"
>
{{ isGeneratingGreeting ? "Generating..." : "Save & Generate" }}
</button>
</div>
</div>
<!-- Delays -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label
class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
>Answer Delay (s)</label
>
<input
v-model.number="config.voicemail_auto_answer_delay_seconds"
type="number"
min="1"
max="120"
class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
@change="
updateConfig({
voicemail_auto_answer_delay_seconds:
config.voicemail_auto_answer_delay_seconds,
})
"
/>
</div>
<div class="space-y-2">
<label
class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
>Max Recording (s)</label
>
<input
v-model.number="config.voicemail_max_recording_seconds"
type="number"
min="5"
max="600"
class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
@change="
updateConfig({
voicemail_max_recording_seconds: config.voicemail_max_recording_seconds,
})
"
/>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -292,6 +571,17 @@ export default {
isCallEnded: false,
lastCall: null,
endedTimeout: null,
activeTab: "phone",
voicemails: [],
unreadVoicemailsCount: 0,
voicemailStatus: {
has_espeak: false,
has_ffmpeg: false,
is_recording: false,
},
isGeneratingGreeting: false,
playingVoicemailId: null,
audioPlayer: null,
};
},
computed: {
@@ -307,15 +597,19 @@ export default {
this.getAudioProfiles();
this.getStatus();
this.getHistory();
this.getVoicemails();
this.getVoicemailStatus();
// poll for status
this.statusInterval = setInterval(() => {
this.getStatus();
this.getVoicemailStatus();
}, 1000);
// poll for history less frequently
// poll for history/voicemails less frequently
this.historyInterval = setInterval(() => {
this.getHistory();
this.getVoicemails();
}, 10000);
// autofill destination hash from query string
@@ -329,6 +623,10 @@ export default {
if (this.statusInterval) clearInterval(this.statusInterval);
if (this.historyInterval) clearInterval(this.historyInterval);
if (this.endedTimeout) clearTimeout(this.endedTimeout);
if (this.audioPlayer) {
this.audioPlayer.pause();
this.audioPlayer = null;
}
},
methods: {
formatDestinationHash(hash) {
@@ -351,6 +649,15 @@ export default {
console.log(e);
}
},
async updateConfig(config) {
try {
await window.axios.patch("/api/v1/config", config);
await this.getConfig();
ToastUtils.success("Settings saved");
} catch {
ToastUtils.error("Failed to save settings");
}
},
async getAudioProfiles() {
try {
const response = await window.axios.get("/api/v1/telephone/audio-profiles");
@@ -366,12 +673,17 @@ export default {
const oldCall = this.activeCall;
this.activeCall = response.data.active_call;
if (response.data.voicemail) {
this.unreadVoicemailsCount = response.data.voicemail.unread_count;
}
// If call just ended, refresh history and show ended state
if (oldCall != null && this.activeCall == null) {
this.getHistory();
this.getVoicemails();
this.lastCall = oldCall;
this.isCallEnded = true;
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => {
this.isCallEnded = false;
@@ -395,6 +707,72 @@ export default {
console.log(e);
}
},
async getVoicemailStatus() {
try {
const response = await window.axios.get("/api/v1/telephone/voicemail/status");
this.voicemailStatus = response.data;
} catch (e) {
console.log(e);
}
},
async getVoicemails() {
try {
const response = await window.axios.get("/api/v1/telephone/voicemails");
this.voicemails = response.data.voicemails;
this.unreadVoicemailsCount = response.data.unread_count;
} catch (e) {
console.log(e);
}
},
async generateGreeting() {
this.isGeneratingGreeting = true;
try {
await window.axios.post("/api/v1/telephone/voicemail/generate-greeting");
ToastUtils.success("Greeting generated successfully");
} catch (e) {
ToastUtils.error(e.response?.data?.message || "Failed to generate greeting");
} finally {
this.isGeneratingGreeting = false;
}
},
async playVoicemail(voicemail) {
if (this.playingVoicemailId === voicemail.id) {
this.audioPlayer.pause();
this.playingVoicemailId = null;
return;
}
if (this.audioPlayer) {
this.audioPlayer.pause();
}
this.playingVoicemailId = voicemail.id;
this.audioPlayer = new Audio(`/api/v1/telephone/voicemails/${voicemail.id}/audio`);
this.audioPlayer.play();
this.audioPlayer.onended = () => {
this.playingVoicemailId = null;
};
// Mark as read
if (!voicemail.is_read) {
try {
await window.axios.post(`/api/v1/telephone/voicemails/${voicemail.id}/read`);
voicemail.is_read = 1;
this.unreadVoicemailsCount = Math.max(0, this.unreadVoicemailsCount - 1);
} catch (e) {
console.error(e);
}
}
},
async deleteVoicemail(voicemailId) {
try {
await window.axios.delete(`/api/v1/telephone/voicemails/${voicemailId}`);
this.getVoicemails();
ToastUtils.success("Voicemail deleted");
} catch {
ToastUtils.error("Failed to delete voicemail");
}
},
async call(identityHash) {
if (!identityHash) {
ToastUtils.error("Enter an identity hash to call");

View File

@@ -388,7 +388,9 @@
<div class="border-t border-gray-100 dark:border-zinc-800 pt-4 space-y-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">MBTiles Storage Directory</label>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1"
>MBTiles Storage Directory</label
>
<input
v-model="mbtilesDir"
type="text"
@@ -407,8 +409,14 @@
class="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800"
>
<div class="flex flex-col min-w-0 flex-1 mr-2">
<span class="text-xs font-medium text-gray-900 dark:text-zinc-100 truncate" :title="file.name">{{ file.name }}</span>
<span class="text-[10px] text-gray-500">{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span>
<span
class="text-xs font-medium text-gray-900 dark:text-zinc-100 truncate"
:title="file.name"
>{{ file.name }}</span
>
<span class="text-[10px] text-gray-500"
>{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span
>
</div>
<div class="flex items-center space-x-1">
<button
@@ -648,7 +656,8 @@ export default {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
this.offlineEnabled = this.config.map_offline_enabled;
this.cachingEnabled = this.config.map_tile_cache_enabled !== undefined ? this.config.map_tile_cache_enabled : true;
this.cachingEnabled =
this.config.map_tile_cache_enabled !== undefined ? this.config.map_tile_cache_enabled : true;
this.mbtilesDir = this.config.map_mbtiles_dir || "";
if (this.config.map_tile_server_url) {
this.tileServerUrl = this.config.map_tile_server_url;
@@ -674,7 +683,7 @@ export default {
await this.checkOfflineMap();
await this.loadMBTilesList();
ToastUtils.success("Map source updated");
} catch (e) {
} catch {
ToastUtils.error("Failed to set active map");
}
},
@@ -687,7 +696,7 @@ export default {
await this.checkOfflineMap();
}
ToastUtils.success("File deleted");
} catch (e) {
} catch {
ToastUtils.error("Failed to delete file");
}
},
@@ -698,7 +707,7 @@ export default {
});
ToastUtils.success("Storage directory saved");
this.loadMBTilesList();
} catch (e) {
} catch {
ToastUtils.error("Failed to save directory");
}
},
@@ -800,7 +809,7 @@ export default {
const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl, "tile");
let tileUrl;
if (isOffline) {
if (isCustomLocal || (!isDefaultOnline && customTileUrl !== defaultTileUrl)) {
@@ -811,14 +820,14 @@ export default {
} else {
tileUrl = customTileUrl;
}
const source = new XYZ({
url: tileUrl,
crossOrigin: "anonymous",
});
const originalTileLoadFunction = source.getTileLoadFunction();
if (isOffline) {
source.setTileLoadFunction(async (tile, src) => {
try {
@@ -832,7 +841,7 @@ export default {
}
const blob = await response.blob();
tile.getImage().src = URL.createObjectURL(blob);
} catch (error) {
} catch {
tile.setState(3);
}
});
@@ -902,15 +911,15 @@ export default {
if (enabled) {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl, "tile");
const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;
const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
const hasCustomNominatim = this.nominatimApiUrl && this.nominatimApiUrl !== defaultNominatimUrl;
if (hasCustomTile && !isCustomTileLocal && !isDefaultTileOnline) {
const isAccessible = await this.checkApiConnection(this.tileServerUrl);
if (!isAccessible) {
@@ -918,7 +927,7 @@ export default {
return;
}
}
if (hasCustomNominatim && !isCustomNominatimLocal && !isDefaultNominatimOnline) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);
if (!isAccessible) {
@@ -1199,7 +1208,7 @@ export default {
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
if (this.offlineEnabled) {
if (isCustomLocal || (!isDefaultOnline && this.nominatimApiUrl !== defaultNominatimUrl)) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);

View File

@@ -1,6 +1,6 @@
@font-face {
font-family: 'Roboto Mono Nerd Font';
src: url('./RobotoMonoNerdFont-Regular.ttf') format('truetype');
font-family: "Roboto Mono Nerd Font";
src: url("./RobotoMonoNerdFont-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}

View File

@@ -1,33 +1,35 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
<title>Reticulum MeshChat</title>
</head>
<body class="bg-gray-100">
<div id="app"></div>
<script type="module" src="main.js"></script>
<script>
// install service worker
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/service-worker.js').catch((error) => {
// Silently handle SSL certificate errors and other registration failures
// This is common in development with self-signed certificates
const errorMessage = error.message || '';
const errorName = error.name || '';
if (errorName === 'SecurityError' || errorMessage.includes('SSL certificate') || errorMessage.includes('certificate')) {
return;
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png" />
<title>Reticulum MeshChat</title>
</head>
<body class="bg-gray-100">
<div id="app"></div>
<script type="module" src="main.js"></script>
<script>
// install service worker
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js").catch((error) => {
// Silently handle SSL certificate errors and other registration failures
// This is common in development with self-signed certificates
const errorMessage = error.message || "";
const errorName = error.name || "";
if (
errorName === "SecurityError" ||
errorMessage.includes("SSL certificate") ||
errorMessage.includes("certificate")
) {
return;
}
// Log other errors for debugging but don't throw
console.debug("Service worker registration failed:", error);
});
}
// Log other errors for debugging but don't throw
console.debug('Service worker registration failed:', error);
});
}
</script>
</body>
</script>
</body>
</html>

View File

@@ -79,7 +79,7 @@ class Utils {
static formatTimeAgo(datetimeString) {
if (!datetimeString) return "unknown";
// ensure UTC if no timezone is provided
let dateString = datetimeString;
if (typeof dateString === "string" && !dateString.includes("Z") && !dateString.includes("+")) {
@@ -87,7 +87,7 @@ class Utils {
// Replace space with T and append Z for ISO format
dateString = dateString.replace(" ", "T") + "Z";
}
const millisecondsAgo = Date.now() - new Date(dateString).getTime();
const secondsAgo = Math.round(millisecondsAgo / 1000);
return this.formatSeconds(secondsAgo);

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
"""
Auto-generated helper so Python tooling and the Electron build
"""Auto-generated helper so Python tooling and the Electron build
share the same version string.
"""
__version__ = '2.50.0'
__version__ = "2.50.0"