feat(announce_manager): add aspect store configuration and announce storage logic

This commit is contained in:
Ivan
2026-04-27 11:15:08 -05:00
parent 6f186b3986
commit 356aea3560
13 changed files with 542 additions and 38 deletions
+23
View File
@@ -18,6 +18,14 @@ _ASPECT_FETCH_LIMIT_KEYS = {
"lxst.telephony": "announce_fetch_limit_lxmf_delivery",
}
_ASPECT_STORE_ENABLE_KEYS = {
"lxmf.delivery": "announce_store_lxmf_delivery",
"lxst.telephony": "announce_store_lxst_telephony",
"nomadnetwork.node": "announce_store_nomadnetwork_node",
"lxmf.propagation": "announce_store_lxmf_propagation",
"git.repositories": "announce_store_git_repositories",
}
class AnnounceManager:
def __init__(self, db: Database, config=None):
@@ -50,6 +58,17 @@ class AnnounceManager:
return 500
return min(v, 100_000)
def is_storing_announce_for_aspect(self, aspect, force_store: bool = False) -> bool:
if force_store or not self.config:
return True
key = _ASPECT_STORE_ENABLE_KEYS.get(aspect)
if not key:
return True
attr = getattr(self.config, key, None)
if attr is None:
return True
return bool(attr.get())
def upsert_announce(
self,
reticulum,
@@ -58,7 +77,11 @@ class AnnounceManager:
aspect,
app_data,
announce_packet_hash,
force_store: bool = False,
):
if not self.is_storing_announce_for_aspect(aspect, force_store=force_store):
return
rssi = snr = quality = None
if announce_packet_hash and reticulum:
rssi = reticulum.get_packet_rssi(announce_packet_hash)
@@ -8,6 +8,7 @@ from LXMF.LXMRouter import LXMRouter
from meshchatx.src.backend.async_utils import AsyncUtils
from meshchatx.src.backend.meshchat_utils import parse_lxmf_propagation_node_app_data
from meshchatx.src.backend import reticulum_pathfinding
_PROP_FAILURE_STATES = frozenset(
{
@@ -57,15 +58,13 @@ class AutoPropagationManager:
await asyncio.sleep(self._check_interval)
async def _wait_for_path(self, dest_hash: bytes, timeout: float) -> bool:
if RNS.Transport.has_path(dest_hash):
return True
RNS.Transport.request_path(dest_hash)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if RNS.Transport.has_path(dest_hash):
return True
await asyncio.sleep(POLL_INTERVAL_SECONDS)
return RNS.Transport.has_path(dest_hash)
r = self.app.reticulum if self.app and hasattr(self.app, "reticulum") else None
return await reticulum_pathfinding.wait_for_path(
r,
dest_hash,
timeout,
poll_interval=POLL_INTERVAL_SECONDS,
)
async def _probe_propagation_sync(self, node_hex: str) -> bool:
ctx = self.context
+48 -2
View File
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: 0BSD
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -8,16 +9,23 @@ _BUNDLED = Path(__file__).resolve().parent / "data" / "community_interfaces.json
class CommunityInterfacesManager:
"""Load suggested interface presets from bundled data or public/community_interfaces.json."""
"""Load suggested interface presets from cache, public override, or bundled data."""
def __init__(self, public_override_path: str | None = None):
def __init__(
self,
public_override_path: str | None = None,
cache_path: str | Path | None = None,
):
self._public_override_path = public_override_path
self._cache_path = Path(cache_path) if cache_path else None
self.interfaces = self._load_raw()
def _candidate_paths(self) -> list[Path]:
paths: list[Path] = []
if self._public_override_path:
paths.append(Path(self._public_override_path))
if self._cache_path:
paths.append(self._cache_path)
paths.append(_BUNDLED)
return paths
@@ -67,5 +75,43 @@ class CommunityInterfacesManager:
out["target_port"] = int(tp)
return out
def refresh_from_directory(
self, url: str | None = None, timeout: float = 60.0
) -> dict[str, Any]:
from meshchatx.src.backend.community_interfaces_directory import (
build_interfaces_from_directory_url,
)
interfaces, resolved_url = build_interfaces_from_directory_url(
url, timeout=timeout
)
if not interfaces:
raise ValueError("Directory returned no usable interface presets")
if self._cache_path:
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
doc = {
"_comment": "MeshChatX cache from in-app directory refresh. Load order is public "
"community_interfaces.json, then this cache, then bundled presets.",
"_source": resolved_url,
"_refreshed_at": datetime.now(timezone.utc)
.replace(microsecond=0)
.isoformat(),
"interfaces": interfaces,
}
self._cache_path.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
self.interfaces = self._load_raw()
else:
self.interfaces = [
self._normalize_entry(dict(x))
for x in interfaces
if isinstance(x, dict)
]
if not self.interfaces:
raise ValueError("No interface presets after refresh")
return {"count": len(self.interfaces), "source": resolved_url}
async def get_interfaces(self) -> list[dict[str, Any]]:
return [{**iface, "online": None, "last_check": 0} for iface in self.interfaces]
@@ -4,7 +4,10 @@
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from typing import Any
DEFAULT_SUBMITTED_URL = (
@@ -13,6 +16,31 @@ DEFAULT_SUBMITTED_URL = (
DESCRIPTION = "directory.rns.recipes (user-submitted, online)"
def fetch_directory_payload(url: str, *, timeout: float = 60.0) -> object:
req = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": "MeshChatX-community-interfaces/1.0 (+https://meshchatx.com/)",
},
method="GET",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def build_interfaces_from_directory_url(
url: str | None = None,
*,
timeout: float = 60.0,
) -> tuple[list[dict[str, Any]], str]:
resolved = url or DEFAULT_SUBMITTED_URL
payload = fetch_directory_payload(resolved, timeout=timeout)
rows = rows_from_payload(payload)
return transform_directory_rows(rows), resolved
_RE_REMOTE = re.compile(r"^\s*remote\s*=\s*(\S+)", re.MULTILINE | re.IGNORECASE)
_RE_TARGET_HOST = re.compile(
r"^\s*target_host\s*=\s*(\S+)",
+68 -1
View File
@@ -256,7 +256,16 @@ class ConfigManager:
self.telemetry_enabled = self.BoolConfig(self, "telemetry_enabled", False)
# translator config
self.translator_enabled = self.BoolConfig(self, "translator_enabled", False)
self.translator_argos_enabled = self.BoolConfig(
self,
"translator_argos_enabled",
False,
)
self.translator_libretranslate_enabled = self.BoolConfig(
self,
"translator_libretranslate_enabled",
False,
)
self.libretranslate_url = self.StringConfig(
self,
"libretranslate_url",
@@ -337,6 +346,33 @@ class ConfigManager:
"#e5e7eb",
)
# When False, meshchat does not persist received announces of that type (all True by default).
self.announce_store_lxmf_delivery = self.BoolConfig(
self,
"announce_store_lxmf_delivery",
True,
)
self.announce_store_lxst_telephony = self.BoolConfig(
self,
"announce_store_lxst_telephony",
True,
)
self.announce_store_nomadnetwork_node = self.BoolConfig(
self,
"announce_store_nomadnetwork_node",
True,
)
self.announce_store_lxmf_propagation = self.BoolConfig(
self,
"announce_store_lxmf_propagation",
True,
)
self.announce_store_git_repositories = self.BoolConfig(
self,
"announce_store_git_repositories",
True,
)
# announce caps: max rows stored per aspect (oldest dropped). Default 1000.
self.announce_max_stored_lxmf_delivery = self.IntConfig(
self,
@@ -425,7 +461,29 @@ class ConfigManager:
"[]",
)
self.local_message_auto_delete_enabled = self.BoolConfig(
self,
"local_message_auto_delete_enabled",
False,
)
self.local_message_auto_delete_value = self.IntConfig(
self,
"local_message_auto_delete_value",
30,
)
self.local_message_auto_delete_unit = self.StringConfig(
self,
"local_message_auto_delete_unit",
"days",
)
self.local_message_auto_delete_last_run_at = self.IntConfig(
self,
"local_message_auto_delete_last_run_at",
None,
)
self._migrate_legacy_announce_limit_keys()
self._migrate_translator_from_legacy()
def get(self, key: str, default_value=None) -> str | None:
return self.db.config.get(key, default_value)
@@ -433,6 +491,15 @@ class ConfigManager:
def set(self, key: str, value: str | None):
self.db.config.set(key, value)
def _migrate_translator_from_legacy(self):
old = self.db.config.get("translator_enabled", default=None)
a = self.db.config.get("translator_argos_enabled", default=None)
libre = self.db.config.get("translator_libretranslate_enabled", default=None)
if old is not None and a is None and libre is None:
v = "true" if str(old).lower() == "true" else "false"
self.db.config.set("translator_argos_enabled", v)
self.db.config.set("translator_libretranslate_enabled", v)
def _migrate_legacy_announce_limit_keys(self):
pairs = [
("announce_limit_lxmf_delivery", "announce_max_stored_lxmf_delivery"),
+61 -1
View File
@@ -37,11 +37,27 @@ class MessageDAO:
"is_spam",
"reply_to_hash",
"attachments_stripped",
"path_hops_at_send",
"path_interface_at_send",
"path_finding_measure",
"path_row_hash_hex",
]
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "hash"])
update_fields = [
f
for f in fields
if f != "hash"
and f
not in (
"path_hops_at_send",
"path_interface_at_send",
"path_finding_measure",
"path_row_hash_hex",
)
]
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in update_fields])
query = (
f"INSERT INTO lxmf_messages ({columns}, created_at, updated_at) VALUES ({placeholders}, ?, ?) "
@@ -61,6 +77,17 @@ class MessageDAO:
self.provider.execute(query, params)
def set_lxmf_message_path_at_send_if_unset(
self, message_hash, hops, interface_name
):
"""Store Reticulum path snapshot once (send or receive); never overwrites."""
now = datetime.now(UTC).isoformat()
self.provider.execute(
"UPDATE lxmf_messages SET path_hops_at_send = ?, path_interface_at_send = ?, updated_at = ? "
"WHERE hash = ? AND path_hops_at_send IS NULL",
(hops, interface_name, now, message_hash),
)
def update_lxmf_message_state(
self,
message_hash,
@@ -166,6 +193,39 @@ class MessageDAO:
self.set_peer_pinned(peer_hash, True)
return True
def list_message_hashes_with_timestamp_before(self, cutoff_ts: float) -> list[str]:
rows = self.provider.fetchall(
"SELECT hash FROM lxmf_messages WHERE timestamp IS NOT NULL AND timestamp < ?",
(cutoff_ts,),
)
return [r["hash"] for r in rows if r.get("hash")]
def prune_conversation_metadata_for_peers_with_no_messages(self) -> None:
self.provider.execute(
"""
DELETE FROM lxmf_conversation_read_state
WHERE destination_hash NOT IN (
SELECT DISTINCT peer_hash FROM lxmf_messages WHERE peer_hash IS NOT NULL
)
""",
)
self.provider.execute(
"""
DELETE FROM lxmf_conversation_folders
WHERE peer_hash NOT IN (
SELECT DISTINCT peer_hash FROM lxmf_messages WHERE peer_hash IS NOT NULL
)
""",
)
self.provider.execute(
"""
DELETE FROM lxmf_conversation_pins
WHERE peer_hash NOT IN (
SELECT DISTINCT peer_hash FROM lxmf_messages WHERE peer_hash IS NOT NULL
)
""",
)
def delete_lxmf_messages_by_hashes(self, message_hashes):
if not message_hashes:
return
+13 -1
View File
@@ -15,7 +15,7 @@ def _validate_identifier(name: str, label: str = "identifier") -> str:
class DatabaseSchema:
LATEST_VERSION = 46
LATEST_VERSION = 48
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -220,6 +220,10 @@ class DatabaseSchema:
quality REAL,
is_spam INTEGER DEFAULT 0,
reply_to_hash TEXT,
path_hops_at_send INTEGER,
path_interface_at_send TEXT,
path_finding_measure TEXT,
path_row_hash_hex TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
@@ -1250,6 +1254,14 @@ class DatabaseSchema:
"CREATE INDEX IF NOT EXISTS idx_user_stickers_pack ON user_stickers(pack_id, sort_order)",
)
if current_version < 47:
self._ensure_column("lxmf_messages", "path_hops_at_send", "INTEGER")
self._ensure_column("lxmf_messages", "path_interface_at_send", "TEXT")
if current_version < 48:
self._ensure_column("lxmf_messages", "path_finding_measure", "TEXT")
self._ensure_column("lxmf_messages", "path_row_hash_hex", "TEXT")
# Update version in config
self._safe_execute(
"""
+16 -2
View File
@@ -273,10 +273,10 @@ class IdentityContext:
)
libretranslate_url = self.config.libretranslate_url.get()
translator_enabled = self.config.translator_enabled.get()
self.translator_handler = TranslatorHandler(
libretranslate_url=libretranslate_url,
enabled=translator_enabled,
translator_argos_enabled=self.config.translator_argos_enabled.get(),
translator_libretranslate_enabled=self.config.translator_libretranslate_enabled.get(),
)
self.bot_handler = BotHandler(
@@ -339,6 +339,10 @@ class IdentityContext:
self.community_interfaces_manager = CommunityInterfacesManager(
public_override_path=self.app.get_public_path("community_interfaces.json"),
cache_path=os.path.join(
self.storage_path,
"community_interfaces_cache.json",
),
)
self.auto_propagation_manager = AutoPropagationManager(
@@ -402,6 +406,16 @@ class IdentityContext:
thread.daemon = True
thread.start()
# start background thread for local (device-only) message age retention
thread = threading.Thread(
target=asyncio.run,
args=(
self.app.local_message_retention_loop(self.session_id, context=self),
),
)
thread.daemon = True
thread.start()
# start background thread for auto propagation node selection
thread = threading.Thread(
target=asyncio.run,
@@ -0,0 +1,70 @@
# SPDX-License-Identifier: 0BSD
"""Local-only age-based deletion of stored LXMF rows (this device, no network signal)."""
import logging
from collections.abc import Callable
log = logging.getLogger(__name__)
UNIT_DAYS = "days"
UNIT_MONTHS = "months"
SECONDS_PER_DAY = 86400
SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY
RETENTION_CHECK_INTERVAL_SECONDS = 3600
LOCAL_RETENTION_STARTUP_GRACE_SECONDS = 120
MAX_VALUE_DAYS = 10_000
MAX_VALUE_MONTHS = 120
def normalize_unit(raw: str | None) -> str:
s = (raw or UNIT_DAYS).strip().lower()
if s in ("month", "months", "mo", "m"):
return UNIT_MONTHS
return UNIT_DAYS
def retention_window_seconds(value: int, unit: str) -> int:
try:
v = int(value)
except (TypeError, ValueError):
v = 1
u = normalize_unit(unit)
if u == UNIT_MONTHS:
return max(1, min(v, MAX_VALUE_MONTHS)) * SECONDS_PER_MONTH
return max(1, min(v, MAX_VALUE_DAYS)) * SECONDS_PER_DAY
def local_message_retention_cutoff_ts(now: float, value: int, unit: str) -> float:
return float(now) - float(retention_window_seconds(value, unit))
def apply_local_message_retention(
messages,
cancel_outbound: Callable[[bytes], None] | None,
*,
value: int,
unit: str,
now: float,
) -> int:
"""Delete local LXMF message rows older than the retention window.
Does not contact peers; only removes rows from the local database.
"""
cutoff = local_message_retention_cutoff_ts(now, value, unit)
hashes = messages.list_message_hashes_with_timestamp_before(cutoff)
if not hashes:
return 0
if cancel_outbound is not None:
for h in hashes:
if not h or len(h) % 2 != 0:
continue
try:
cancel_outbound(bytes.fromhex(h))
except Exception as exc: # noqa: BLE001
log.debug("local_message_retention cancel_outbound: %s", exc)
messages.delete_lxmf_messages_by_hashes(hashes)
messages.prune_conversation_metadata_for_peers_with_no_messages()
return len(hashes)
+5 -1
View File
@@ -510,7 +510,7 @@ def convert_db_lxmf_message_to_dict(
"is_incoming": bool(db_lxmf_message["is_incoming"]),
"state": db_lxmf_message["state"],
"progress": db_lxmf_message["progress"],
"method": db_lxmf_message["method"],
"method": db_lxmf_message.get("method") or "unknown",
"delivery_attempts": db_lxmf_message["delivery_attempts"],
"next_delivery_attempt_at": db_lxmf_message["next_delivery_attempt_at"],
"title": db_lxmf_message["title"],
@@ -523,6 +523,10 @@ def convert_db_lxmf_message_to_dict(
"is_spam": bool(db_lxmf_message["is_spam"]),
"reply_to_hash": db_lxmf_message.get("reply_to_hash"),
"attachments_stripped": bool(db_lxmf_message.get("attachments_stripped", 0)),
"path_hops_at_send": db_lxmf_message.get("path_hops_at_send"),
"path_interface_at_send": db_lxmf_message.get("path_interface_at_send"),
"path_finding_measure": db_lxmf_message.get("path_finding_measure"),
"path_row_hash_hex": db_lxmf_message.get("path_row_hash_hex"),
"created_at": created_at,
"updated_at": updated_at,
"is_reaction": is_reaction,
+1 -1
View File
@@ -101,7 +101,7 @@ class MarkdownRenderer:
# Inline code
text = re.sub(
r"`([^`]+)`",
r'<code class="bg-gray-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded text-pink-600 dark:text-pink-400 font-mono text-[0.9em]">\1</code>',
r'<code class="bg-gray-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-sm text-pink-600 dark:text-pink-400 font-mono text-[0.9em]">\1</code>',
text,
)
@@ -0,0 +1,150 @@
# SPDX-License-Identifier: 0BSD
import asyncio
import contextlib
import time
from dataclasses import dataclass
from typing import Any, Optional, Protocol
import RNS
@dataclass(frozen=True)
class OutboundPathOutcome:
"""Result of waiting for a Reticulum transport path before outbound LXMF."""
path_available: bool
prepare_measure: str
used_nudge: bool
def format_outbound_path_finding_measure(outcome: OutboundPathOutcome) -> str:
"""Single string for storage/API: base measure, plus ``+nudge`` if a nudge was used."""
base = outcome.prepare_measure
if outcome.used_nudge:
return f"{base}+nudge"
return base
IDX_PT_TIMESTAMP = 0
IDX_PT_RVCD_IF = 5
def _path_table_entry_is_expired_by_reticulum_rules(entry) -> bool:
ts = entry[IDX_PT_TIMESTAMP]
attached = entry[IDX_PT_RVCD_IF]
if attached is not None and hasattr(attached, "mode"):
iface = RNS.Interfaces.Interface.Interface
if attached.mode == iface.MODE_ACCESS_POINT:
dest_exp = ts + RNS.Transport.AP_PATH_TIME
elif attached.mode == iface.MODE_ROAMING:
dest_exp = ts + RNS.Transport.ROAMING_PATH_TIME
else:
dest_exp = ts + RNS.Transport.DESTINATION_TIMEOUT
else:
dest_exp = ts + RNS.Transport.DESTINATION_TIMEOUT
return time.time() > dest_exp
def transport_path_table_entry_is_expired(destination_hash: bytes) -> bool:
with RNS.Transport.path_table_lock:
if destination_hash not in RNS.Transport.path_table:
return True
entry = RNS.Transport.path_table[destination_hash]
return _path_table_entry_is_expired_by_reticulum_rules(entry)
def should_rediscover_path(destination_hash: bytes) -> bool:
if not RNS.Transport.has_path(destination_hash):
return True
if RNS.Transport.path_is_unresponsive(destination_hash):
return True
return transport_path_table_entry_is_expired(destination_hash)
def path_metadata_for_api(destination_hash: bytes) -> dict[str, bool]:
has = RNS.Transport.has_path(destination_hash)
if not has:
return {
"path_stale": True,
"path_unresponsive": False,
}
return {
"path_stale": transport_path_table_entry_is_expired(destination_hash),
"path_unresponsive": RNS.Transport.path_is_unresponsive(destination_hash),
}
def prepare_fresh_path_request(
reticulum: Optional["ReticulumLike"], destination_hash: bytes
) -> str:
"""Ensure a path request is in flight if needed.
Returns a stable label for what was done before waiting:
``reused_valid_path`` (no new request), ``path_refresh_requested`` (dropped
or expired then requested), or ``new_path_requested`` (no prior path).
"""
if not should_rediscover_path(destination_hash):
return "reused_valid_path"
had_path = RNS.Transport.has_path(destination_hash)
if had_path:
if reticulum is not None:
with contextlib.suppress(Exception):
reticulum.drop_path(destination_hash)
else:
RNS.Transport.expire_path(destination_hash)
RNS.Transport.request_path(destination_hash)
return "path_refresh_requested" if had_path else "new_path_requested"
def nudge_path_request(destination_hash: bytes) -> None:
RNS.Transport.request_path(destination_hash)
def lxmf_path_wait_cap_seconds() -> float:
try:
base = float(RNS.Transport.PATH_REQUEST_TIMEOUT)
except Exception:
base = 30.0
return max(30.0, min(base, 120.0))
async def await_transport_path_for_outbound_lxmf(
reticulum: Optional["ReticulumLike"],
destination_hash_bytes: bytes,
) -> OutboundPathOutcome:
long_w = lxmf_path_wait_cap_seconds()
short_w = max(15.0, long_w * 0.5)
measure = prepare_fresh_path_request(reticulum, destination_hash_bytes)
deadline = time.time() + long_w
while not RNS.Transport.has_path(destination_hash_bytes) and time.time() < deadline:
await asyncio.sleep(0.1)
if RNS.Transport.has_path(destination_hash_bytes):
return OutboundPathOutcome(True, measure, False)
nudge_path_request(destination_hash_bytes)
deadline = time.time() + short_w
while not RNS.Transport.has_path(destination_hash_bytes) and time.time() < deadline:
await asyncio.sleep(0.1)
ok = RNS.Transport.has_path(destination_hash_bytes)
return OutboundPathOutcome(ok, measure, True)
class ReticulumLike(Protocol):
def drop_path(self, destination) -> Any: ...
async def wait_for_path(
reticulum: Optional["ReticulumLike"],
dest_hash: bytes,
path_wait_timeout_seconds: float,
poll_interval: float = 0.1,
) -> bool:
prepare_fresh_path_request(reticulum, dest_hash)
deadline = time.monotonic() + path_wait_timeout_seconds
while time.monotonic() < deadline:
if RNS.Transport.has_path(dest_hash):
return True
await asyncio.sleep(poll_interval)
return RNS.Transport.has_path(dest_hash)
+51 -20
View File
@@ -91,8 +91,14 @@ def _sync_run_coro(coro):
class TranslatorHandler:
def __init__(self, libretranslate_url: str | None = None, enabled: bool = False):
self.enabled = enabled
def __init__(
self,
libretranslate_url: str | None = None,
translator_argos_enabled: bool = False,
translator_libretranslate_enabled: bool = False,
):
self.translator_argos_enabled = translator_argos_enabled
self.translator_libretranslate_enabled = translator_libretranslate_enabled
self.libretranslate_url = libretranslate_url or os.getenv(
"LIBRETRANSLATE_URL",
"http://localhost:5000",
@@ -103,6 +109,18 @@ class TranslatorHandler:
self.has_argos = self.has_argos_lib or self.has_argos_cli
self.has_requests = HAS_AIOHTTP
def _any_backend_config_enabled(self) -> bool:
return self.translator_argos_enabled or self.translator_libretranslate_enabled
@property
def enabled(self) -> bool:
return self._any_backend_config_enabled()
@enabled.setter
def enabled(self, value: bool) -> None:
self.translator_argos_enabled = value
self.translator_libretranslate_enabled = value
async def _fetch_languages_async(self, url: str):
base = url.rstrip("/")
timeout = aiohttp.ClientTimeout(total=5)
@@ -115,23 +133,26 @@ class TranslatorHandler:
return await response.json()
return None
def get_supported_languages(self, libretranslate_url: str | None = None):
languages = []
if not self.enabled:
return languages
def get_translator_languages_response(
self,
libretranslate_url: str | None = None,
) -> dict[str, Any]:
"""List installed/reachable language pairs for UI; not gated on enable toggles."""
languages: list[dict[str, str]] = []
libretranslate_reachable = False
url = libretranslate_url or self.libretranslate_url
if libretranslate_url is not None and str(libretranslate_url).strip():
try:
url = normalize_loopback_http_service_base(libretranslate_url)
except UnsafeOutboundUrlError as e:
msg = str(e)
raise ValueError(msg) from e
if self.has_requests:
if libretranslate_url is not None and str(libretranslate_url).strip():
try:
url = normalize_loopback_http_service_base(libretranslate_url)
except UnsafeOutboundUrlError as e:
msg = str(e)
raise ValueError(msg) from e
try:
libretranslate_langs = _sync_run_coro(self._fetch_languages_async(url))
if libretranslate_langs is not None:
libretranslate_reachable = True
languages.extend(
{
"code": lang.get("code"),
@@ -140,7 +161,6 @@ class TranslatorHandler:
}
for lang in libretranslate_langs
)
return languages
except Exception as e:
print(f"Failed to fetch LibreTranslate languages: {e}")
@@ -169,7 +189,15 @@ class TranslatorHandler:
except Exception as e:
print(f"Failed to fetch Argos languages via CLI: {e}")
return languages
return {
"languages": languages,
"libretranslate_reachable": libretranslate_reachable,
}
def get_supported_languages(self, libretranslate_url: str | None = None) -> list:
return self.get_translator_languages_response(
libretranslate_url=libretranslate_url,
)["languages"]
def translate_text(
self,
@@ -179,7 +207,7 @@ class TranslatorHandler:
use_argos: bool = False,
libretranslate_url: str | None = None,
) -> dict[str, Any]:
if not self.enabled:
if not self._any_backend_config_enabled():
msg = "Translator is disabled"
raise RuntimeError(msg)
@@ -187,10 +215,13 @@ class TranslatorHandler:
msg = "Text cannot be empty"
raise ValueError(msg)
if use_argos and self.has_argos:
if use_argos:
if not self.translator_argos_enabled or not self.has_argos:
msg = "Argos translation is not enabled or not available"
raise RuntimeError(msg)
return self._translate_argos(text, source_lang, target_lang)
if self.has_requests:
if self.translator_libretranslate_enabled and self.has_requests:
try:
url = libretranslate_url or self.libretranslate_url
if libretranslate_url is not None and str(libretranslate_url).strip():
@@ -206,11 +237,11 @@ class TranslatorHandler:
libretranslate_url=url,
)
except Exception as e:
if self.has_argos:
if self.translator_argos_enabled and self.has_argos:
return self._translate_argos(text, source_lang, target_lang)
raise e
if self.has_argos:
if self.translator_argos_enabled and self.has_argos:
return self._translate_argos(text, source_lang, target_lang)
msg = "No translation backend available. Install aiohttp for LibreTranslate or argostranslate for local translation."