Coverage for core / install_links.py: 87.1%
85 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2core.install_links — canonical install-link source of truth.
4Single home for the (target_device, locale) -> install URL mapping.
5Used by:
6 - integrations/channels/agent_tools.py::send_install_link (LLM tool)
7 - docs/downloads.md (human docs)
8 - landing-page download CTAs (via /api/social/install-links if added)
10Per CLAUDE.md Gate 2 (DRY): NEVER inline install URLs anywhere else.
11Import from here. If a URL changes, change ONE line.
13History:
14 - 2026-04-28: created. Replaces the legacy
15 `HevolveAI_Agent_Companion_Setup.exe` ad-hoc string and the
16 scattered `play.google.com/.../com.hertzai.hevolve` references.
17 Canonical URLs sourced from HARTOS/docs/downloads.md.
19Security:
20 - All URLs in CANONICAL_INSTALL_LINKS are checked into the repo
21 and reviewed. The `send_install_link` agent tool accepts an
22 `install_link` override ONLY if the host matches `ALLOWED_HOSTS`
23 — this prevents prompt-injection from steering users to a
24 typosquat / phishing URL.
25"""
27from __future__ import annotations
29from typing import Dict, Optional, Tuple
30from urllib.parse import urlparse
33# ─── Target devices the agent can hand off to ──────────────────────
35SUPPORTED_DEVICES: Tuple[str, ...] = (
36 'android',
37 'ios',
38 'windows',
39 'macos',
40 'linux',
41)
43# ─── Channels through which install links may be sent ──────────────
44#
45# These are the channel_type values accepted by the agent tool.
46# Must be a subset of the registered channel adapters
47# (integrations/channels/registry.py); validated at call time.
49SUPPORTED_INSTALL_CHANNELS: Tuple[str, ...] = (
50 'telegram',
51 'discord',
52 'whatsapp',
53 'slack',
54 'signal',
55 'web', # crossbar/in-app push
56 'email',
57)
60# ─── Canonical install URLs ─────────────────────────────────────────
61#
62# Keyed by (target_device, locale). Locale 'default' is the global
63# fallback; per-locale entries override only when present (e.g. a
64# China-mainland mirror, an India-specific Play Store URL).
66CANONICAL_INSTALL_LINKS: Dict[Tuple[str, str], str] = {
67 # Windows — GitHub release, Azure-Trusted-Signing-signed
68 ('windows', 'default'):
69 'https://github.com/hertz-ai/Nunba/releases/latest/download/Nunba_Setup.exe',
70 # macOS — notarized DMG
71 ('macos', 'default'):
72 'https://github.com/hertz-ai/Nunba/releases/latest/download/Nunba_Setup.dmg',
73 # Linux — AppImage works on every distro
74 ('linux', 'default'):
75 'https://github.com/hertz-ai/Nunba/releases/latest/download/Nunba-x86_64.AppImage',
76 # Android — Google Play (Hevolve Droid is the user-facing brand)
77 ('android', 'default'):
78 'https://play.google.com/store/apps/details?id=com.hertzai.hevolve',
79 # iOS — TestFlight not yet public; provide a coming-soon page
80 ('ios', 'default'):
81 'https://hevolve.ai/ios-coming-soon',
82}
85# ─── Allowed hosts for the `install_link` override parameter ───────
86#
87# When the agent / caller supplies a custom URL, it MUST resolve to
88# one of these hosts. Anything else is rejected as a potential
89# prompt-injection payload.
91ALLOWED_HOSTS: Tuple[str, ...] = (
92 'github.com', # release artifacts (raw + browser)
93 'objects.githubusercontent.com', # GitHub's CDN for release assets
94 'play.google.com', # Android
95 'apps.apple.com', # iOS App Store (future)
96 'hevolve.ai', # marketing landing
97 'docs.hevolve.ai', # docs mirror
98 'testflight.apple.com', # iOS TestFlight (future)
99)
102# ─── Public API ────────────────────────────────────────────────────
104def get_install_link(
105 target_device: str,
106 locale: str = 'default',
107) -> Optional[str]:
108 """Return the canonical install URL for a target device + locale.
110 Args:
111 target_device: one of SUPPORTED_DEVICES
112 locale: BCP-47 language tag (e.g. 'en', 'hi', 'zh') or 'default'
114 Returns:
115 URL string, or None if (device, locale) has no entry AND no
116 'default' fallback exists.
117 """
118 device = (target_device or '').lower().strip()
119 if device not in SUPPORTED_DEVICES:
120 return None
122 # Try locale-specific first, then fall back to 'default'
123 url = CANONICAL_INSTALL_LINKS.get((device, locale))
124 if url:
125 return url
126 return CANONICAL_INSTALL_LINKS.get((device, 'default'))
129def is_allowed_install_link(url: str) -> bool:
130 """Verify a candidate install URL resolves to an allowed host.
132 Used to gate the `install_link` override on the agent tool — a
133 misbehaving or prompt-injected agent MUST NOT be able to send an
134 arbitrary URL to a user's Telegram/Discord/etc.
136 Args:
137 url: candidate URL string
139 Returns:
140 True iff the parsed netloc matches one of ALLOWED_HOSTS
141 (exact match or proper subdomain).
142 """
143 if not isinstance(url, str) or not url:
144 return False
145 try:
146 parsed = urlparse(url)
147 except Exception:
148 return False
149 if parsed.scheme not in ('http', 'https'):
150 return False
151 host = (parsed.netloc or '').lower()
152 # strip port if present
153 host = host.split(':', 1)[0]
154 if not host:
155 return False
156 for allowed in ALLOWED_HOSTS:
157 if host == allowed or host.endswith('.' + allowed):
158 return True
159 return False
162def is_supported_device(device: str) -> bool:
163 """True iff `device` is one of SUPPORTED_DEVICES (case-insensitive)."""
164 return (device or '').lower().strip() in SUPPORTED_DEVICES
167def is_supported_install_channel(channel_type: str) -> bool:
168 """True iff `channel_type` is one of SUPPORTED_INSTALL_CHANNELS."""
169 return (channel_type or '').lower().strip() in SUPPORTED_INSTALL_CHANNELS
172# ─── Custom-scheme deep links (UNIF-G4) ─────────────────────────────
173#
174# Distinct from `is_allowed_install_link` (HTTPS-only) above — these
175# helpers cover the OS-registered custom schemes used by the Nunba
176# desktop / Android / iOS protocol handlers to receive invite-accept,
177# meet-join, and group-join intents from external apps (browser,
178# Telegram, Discord, OS file manager, etc.).
179#
180# Schemes (all three accepted on the receive side; builders default
181# to ``hevolveai`` for the canonical cross-platform link):
182# - hevolveai:// Nunba desktop registers this since 2024;
183# canonical for agent-emitted links.
184# - nunba:// UNIF-G4 brand-canon scheme; iOS + desktop accept.
185# - hevolve:// Legacy mobile scheme; Android + iOS Info.plist
186# have registered it since the early Hevolve_RN
187# builds (referrals, share, campaign, channel).
188# Recognized here so a single ``invite_link()`` URL
189# can be valid on every surface — the desktop
190# protocol handler, Android intent filter, and iOS
191# URL types all accept the same input.
192#
193# Verbs (path[0]):
194# - invite → /invite/<invite_code>
195# - meet → /meet/<platform>/<room_id>
196# - group → /group/<platform>/<group_id>
197#
198# Mobile clients use these deep links to route an inbound share-link
199# tap straight into the matching agent tool (Invite_Friend.accept or
200# Join_External_Room) without going through a web redirect first.
202DEEPLINK_SCHEMES: Tuple[str, ...] = ('hevolveai', 'nunba', 'hevolve')
203DEEPLINK_VERBS: Tuple[str, ...] = ('invite', 'meet', 'group')
206def is_allowed_deeplink_uri(uri: str) -> bool:
207 """Validate a custom-scheme deep-link URI.
209 True iff scheme ∈ DEEPLINK_SCHEMES and the leading path segment is
210 one of DEEPLINK_VERBS with at least one trailing segment.
211 """
212 if not isinstance(uri, str) or not uri:
213 return False
214 try:
215 parsed = urlparse(uri)
216 except Exception:
217 return False
218 scheme = (parsed.scheme or '').lower()
219 if scheme not in DEEPLINK_SCHEMES:
220 return False
221 # urlparse treats `nunba://invite/X` netloc='invite', path='/X'
222 # and `nunba:invite/X` path='invite/X'. Normalize.
223 raw = (parsed.netloc + parsed.path) if parsed.netloc else parsed.path
224 segments = [s for s in (raw or '').split('/') if s]
225 if not segments:
226 return False
227 verb = segments[0].lower()
228 if verb not in DEEPLINK_VERBS:
229 return False
230 # Per-verb arity:
231 # invite needs 1 trailing segment (code) → total ≥ 2
232 # meet / group need 2 trailing segments
233 # (platform + room/group_id) → total ≥ 3
234 required_total = 2 if verb == 'invite' else 3
235 if len(segments) < required_total:
236 return False
237 return True
240def invite_link(invite_code: str, scheme: str = 'hevolveai') -> str:
241 """Build a custom-scheme deep link for accepting a friend invite."""
242 if not invite_code:
243 raise ValueError("invite_link requires a non-empty invite_code")
244 s = (scheme or 'hevolveai').lower()
245 if s not in DEEPLINK_SCHEMES:
246 raise ValueError(f"unsupported scheme {scheme!r}")
247 return f"{s}://invite/{invite_code}"
250def meet_link(platform: str, room_id: str,
251 scheme: str = 'hevolveai') -> str:
252 """Build a custom-scheme deep link for joining an external meet."""
253 if not platform or not room_id:
254 raise ValueError("meet_link requires platform + room_id")
255 s = (scheme or 'hevolveai').lower()
256 if s not in DEEPLINK_SCHEMES:
257 raise ValueError(f"unsupported scheme {scheme!r}")
258 return f"{s}://meet/{platform.lower()}/{room_id}"
261def group_link(platform: str, group_id: str,
262 scheme: str = 'hevolveai') -> str:
263 """Build a custom-scheme deep link for joining an external group."""
264 if not platform or not group_id:
265 raise ValueError("group_link requires platform + group_id")
266 s = (scheme or 'hevolveai').lower()
267 if s not in DEEPLINK_SCHEMES:
268 raise ValueError(f"unsupported scheme {scheme!r}")
269 return f"{s}://group/{platform.lower()}/{group_id}"
272# ─── Public OG / share URL builder ────────────────────────────────
273#
274# F1 of memory/feedback_unification_reuse_contract.md — the SINGLE
275# canonical builder for HTTPS share URLs that crawlers (Discord /
276# Slack / WhatsApp Web / Twitter / Telegram / LinkedIn / FB) fetch
277# to render unfurl previews. Every emit site (InviteService,
278# DistributionService, adapter share-card builders, OG endpoint)
279# must call ``og_url`` — never inline a string.
280#
281# Distinct from the custom-scheme deep-links above:
282# * ``invite_link / meet_link / group_link`` → ``hevolveai://...`` —
283# consumed by the OS protocol handler, opens Nunba app directly.
284# * ``og_url`` → ``https://...`` — consumed by web bots for OG
285# preview rendering; redirects to SPA / app from the resulting
286# OG endpoint.
287#
288# These are NOT parallel paths: they serve different consumers
289# (protocol handler vs HTTP bot fetcher). A single emit site
290# typically uses BOTH (custom-scheme as the click-through CTA inside
291# an HTTPS preview card).
293OG_RESOURCE_TYPES: Tuple[str, ...] = ('i', 'c', 'p', 'u')
296def og_url(resource_type: str, identifier: str,
297 base_url: Optional[str] = None) -> str:
298 """Build canonical public share URL for a resource.
300 Args:
301 resource_type: one of OG_RESOURCE_TYPES — ``i`` (invite),
302 ``c`` (community), ``p`` (post), ``u`` (user/agent).
303 Future expansion: ``m`` (meet), ``g`` (group), ``ch``
304 (channel) — these require canonical Meet / Group /
305 ChannelBinding models first (F8 in the contract).
306 identifier: the canonical ID for the resource (UUID, slug,
307 invite_code, or handle). Must be non-empty.
308 base_url: override. Defaults to
309 ``HEVOLVE_PUBLIC_BASE_URL`` env or
310 ``https://hevolve.ai``.
312 Returns:
313 ``<base>/<resource_type>/<identifier>``. Example:
314 ``https://hevolve.ai/i/A1B2C3``.
316 Raises:
317 ValueError on unknown ``resource_type`` or empty
318 ``identifier``.
320 Single-writer contract: every share-URL emit site in HARTOS +
321 Nunba calls this function. No parallel implementations. See
322 ``memory/feedback_unification_reuse_contract.md``.
323 """
324 if resource_type not in OG_RESOURCE_TYPES:
325 raise ValueError(
326 f"unsupported resource_type {resource_type!r}; "
327 f"known: {OG_RESOURCE_TYPES}"
328 )
329 if not identifier:
330 raise ValueError("og_url requires non-empty identifier")
331 import os as _os
332 base = base_url or _os.environ.get(
333 'HEVOLVE_PUBLIC_BASE_URL', 'https://hevolve.ai'
334 )
335 return f"{base.rstrip('/')}/{resource_type}/{identifier}"