refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts. Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.core import logging as app_logging
|
||||
|
||||
|
||||
async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | None = None) -> list | dict | None:
|
||||
"""Fetch scheduled reports list from S2S_REPORT_LIST.
|
||||
|
||||
Completely generic / deploy-agnostic: performs GET ${S2S_REPORT_LIST} and appends
|
||||
?phoneNumber=<x> if a phone is provided. Accepts JSON (optional {"result": [...]}
|
||||
wrapper) and returns the list or unwrapped list. On any error logs and returns None
|
||||
(never fails the proxy / reasoning tunnel).
|
||||
|
||||
USER FIX 2026-09-11: remove mass injection of scheduled reports.
|
||||
• If S2S_INJECT_REPORTS is FALSE (DEFAULT) we short-circuit to None at the
|
||||
very top — NO HTTP call is made to S2S_REPORT_LIST at all. The upstream
|
||||
endpoint is never hit, not even for a "cheap empty list"; combined with
|
||||
_resolve_reports_context → "" above, this guarantees ZERO scheduled-report
|
||||
contents reach any LLM prompt anywhere (WS session instructions patch,
|
||||
/api/reasoning user_context, reasoning instructions augment, quick reports
|
||||
lookup response).
|
||||
• S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND
|
||||
the full injection into every prompt path).
|
||||
"""
|
||||
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
|
||||
if not settings.S2S_INJECT_REPORTS:
|
||||
return None
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
if not settings.S2S_REPORT_LIST:
|
||||
return None
|
||||
if phone_number:
|
||||
sep = "&" if "?" in settings.S2S_REPORT_LIST else "?"
|
||||
target_url = f"{settings.S2S_REPORT_LIST}{sep}{urlencode({'phoneNumber': phone_number})}"
|
||||
else:
|
||||
target_url = settings.S2S_REPORT_LIST
|
||||
app_logging.logger.info(
|
||||
"[INJECT-REPORTS] fetch upstream URL=%s phoneLen=%s bearer=%s",
|
||||
settings.S2S_REPORT_LIST,
|
||||
len(phone_number) if phone_number else "none",
|
||||
"yes" if auth_token else "no",
|
||||
)
|
||||
try:
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
||||
resp = await http.get(target_url, headers=headers or None)
|
||||
if resp.status_code != 200:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-REPORTS] ⛔ upstream HTTP %s response=%.250s",
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
return None
|
||||
text = (resp.text or "").strip()
|
||||
if not text:
|
||||
app_logging.logger.info("[INJECT-REPORTS] ⏭️ upstream returned empty body.")
|
||||
return None
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-REPORTS] ⛔ upstream returned non-JSON body len=%d; cannot parse list. response=%.250s",
|
||||
len(text),
|
||||
text,
|
||||
)
|
||||
return None
|
||||
except Exception as exc:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-REPORTS] ⛔ fetch unreachable error=%r",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
# Unwrap {"result": [...]} or other common list-wrapping dict keys
|
||||
# (e.g. {"data": [...], "reports": [...], "items": [...], "content": [...], "value": [...]}).
|
||||
unwrapped: object = payload
|
||||
if isinstance(payload, dict):
|
||||
# Log full structure for debugging if enabled
|
||||
if settings.S2S_DEBUG_PROMPT:
|
||||
try:
|
||||
keys_preview = ",".join(sorted(str(k) for k in payload.keys()))
|
||||
preview = json.dumps(payload, ensure_ascii=False)
|
||||
preview = preview[:4000]
|
||||
app_logging.logger.info(
|
||||
"[DEBUG-REPORTS][fetch] upstream response dict keys=[%s] full PREVIEW (len=%d):\n%s\n",
|
||||
keys_preview,
|
||||
len(preview),
|
||||
preview,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Preferred explicit "result" key first
|
||||
if "result" in payload and isinstance(payload["result"], list):
|
||||
unwrapped = payload["result"]
|
||||
else:
|
||||
# Scan all common list-wrapper keys (case-insensitive match on the key)
|
||||
wrapper_keys = {
|
||||
k.lower()
|
||||
for k in (
|
||||
"data",
|
||||
"reports",
|
||||
"reportList",
|
||||
"report_list",
|
||||
"report",
|
||||
"items",
|
||||
"itemList",
|
||||
"item_list",
|
||||
"list",
|
||||
"content",
|
||||
"scheduled",
|
||||
"scheduledReports",
|
||||
"scheduled_reports",
|
||||
"podcastList",
|
||||
"podcast_list",
|
||||
"podcasts",
|
||||
"entries",
|
||||
"records",
|
||||
"rows",
|
||||
"pageContent",
|
||||
"page_content",
|
||||
"value",
|
||||
)
|
||||
}
|
||||
for k, v in payload.items():
|
||||
if isinstance(k, str) and isinstance(v, list) and k.lower() in wrapper_keys:
|
||||
unwrapped = v
|
||||
break
|
||||
# Fallback: if it's still a dict with only ONE list-valued key,
|
||||
# assume that's the list (common pattern like {"data": [...]} with
|
||||
# unusual key name we don't hardcode).
|
||||
if not isinstance(unwrapped, list):
|
||||
list_valued_keys = [k for k, v in payload.items() if isinstance(v, list) and len(v) > 0]
|
||||
if len(list_valued_keys) == 1:
|
||||
unwrapped = payload[list_valued_keys[0]]
|
||||
# Validate array shape
|
||||
if isinstance(unwrapped, list):
|
||||
n_items = len(unwrapped)
|
||||
n_dict = sum(1 for x in unwrapped if isinstance(x, dict))
|
||||
app_logging.logger.info(
|
||||
"[INJECT-REPORTS] ✅ reports list loaded OK (list len=%d dictItems=%d)",
|
||||
n_items,
|
||||
n_dict,
|
||||
)
|
||||
return unwrapped
|
||||
app_logging.logger.info(
|
||||
"[INJECT-REPORTS] ⏭️ upstream returned type=%s (not list); nothing to inject.",
|
||||
type(unwrapped).__name__,
|
||||
)
|
||||
if settings.S2S_DEBUG_PROMPT and isinstance(payload, dict):
|
||||
# Log final top-level keys to help diagnose wrapper shape
|
||||
app_logging.logger.info(
|
||||
"[DEBUG-REPORTS][fetch] wrapper not detected — top-level keys of dict response: [%s] "
|
||||
"(list values under keys: %s)",
|
||||
",".join(sorted(str(k) for k in payload.keys())),
|
||||
",".join(sorted(str(k) + "=list[" + str(len(v)) + "]" for k, v in payload.items() if isinstance(v, list)))
|
||||
or "none",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_login_token(login_payload: dict | None) -> str | None:
|
||||
"""Given the parsed response from the S2S_LOGIN_BY_PHONE endpoint (already
|
||||
{"result":…}-unwrapped), try to extract an auth / session token.
|
||||
Looks under common names (case-insensitive). Returns None if nothing found.
|
||||
"""
|
||||
if not login_payload or not isinstance(login_payload, dict):
|
||||
return None
|
||||
# Unwrap one level of nesting if result is still an dict: handled by caller already.
|
||||
candidates = {
|
||||
c.lower()
|
||||
for c in (
|
||||
"token",
|
||||
"jwt",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"auth_token",
|
||||
"authToken",
|
||||
"session_token",
|
||||
"sessionToken",
|
||||
"id_token",
|
||||
"idToken",
|
||||
"bearer",
|
||||
"bearer_token",
|
||||
"bearerToken",
|
||||
"authentication_token",
|
||||
)
|
||||
}
|
||||
for k, v in login_payload.items():
|
||||
if isinstance(k, str) and k.lower() in candidates and isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
# Second pass: nested dict {"data": {"token": …}} or {"auth": {"jwt": …}}
|
||||
for _k, v in login_payload.items():
|
||||
if isinstance(v, dict):
|
||||
nested = _extract_login_token(v)
|
||||
if nested:
|
||||
return nested
|
||||
# Third pass: list like [{"token": ...}] (uncommon but safe)
|
||||
for _k, v in login_payload.items():
|
||||
if isinstance(v, list) and v:
|
||||
for elem in v:
|
||||
if isinstance(elem, dict):
|
||||
nested = _extract_login_token(elem)
|
||||
if nested:
|
||||
return nested
|
||||
return None
|
||||
|
||||
|
||||
async def _login_by_phone(phone_number: str) -> tuple[str | None, dict | None]:
|
||||
"""Perform login-by-phone: POST S2S_LOGIN_BY_PHONE with JSON
|
||||
{"phoneNumber": phone_number}. Returns (bearer_token_or_None, response_obj_or_None).
|
||||
|
||||
Never fails: on any HTTP/parse error logs a warning and returns (None, None)
|
||||
so subsequent profile/summary calls simply proceed without a Bearer token.
|
||||
"""
|
||||
if not settings.S2S_LOGIN_BY_PHONE or not phone_number:
|
||||
return None, None
|
||||
body = {"phoneNumber": phone_number}
|
||||
app_logging.logger.info(
|
||||
"[INJECT-LOGIN] POST upstream URL=%s phoneLen=%d",
|
||||
settings.S2S_LOGIN_BY_PHONE,
|
||||
len(phone_number),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
||||
resp = await http.post(
|
||||
settings.S2S_LOGIN_BY_PHONE,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-LOGIN] ⛔ upstream HTTP %s phoneLen=%d response=%.250s",
|
||||
resp.status_code,
|
||||
len(phone_number),
|
||||
resp.text,
|
||||
)
|
||||
return None, None
|
||||
text = (resp.text or "").strip()
|
||||
if not text:
|
||||
app_logging.logger.info("[INJECT-LOGIN] ⏭️ upstream returned empty body; no login token available.")
|
||||
return None, None
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-LOGIN] ⛔ upstream returned non-JSON body len=%d; cannot extract token. response=%.250s",
|
||||
len(text),
|
||||
text,
|
||||
)
|
||||
return None, None
|
||||
except Exception as exc:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-LOGIN] ⛔ fetch unreachable phoneLen=%d error=%r",
|
||||
len(phone_number),
|
||||
exc,
|
||||
)
|
||||
return None, None
|
||||
|
||||
# Unwrap {"result": …} OR {"data": …} if present (common wrapper conventions).
|
||||
unwrapped: dict | None = None
|
||||
if isinstance(payload, dict):
|
||||
for _wrap_key in ("result", "data", "payload"):
|
||||
if isinstance(payload.get(_wrap_key), dict):
|
||||
unwrapped = payload[_wrap_key]
|
||||
break
|
||||
if unwrapped is None:
|
||||
unwrapped = payload
|
||||
token = (
|
||||
_extract_login_token(unwrapped)
|
||||
if isinstance(unwrapped, dict)
|
||||
else _extract_login_token(payload if isinstance(payload, dict) else None)
|
||||
)
|
||||
# Always log login-token diagnostics, even without S2S_DEBUG_PROMPT:
|
||||
# token length, first eight characters, response keys and wrapper layer.
|
||||
_all_keys: list[str] = []
|
||||
if isinstance(payload, dict):
|
||||
_all_keys.extend(str(k) for k in payload.keys())
|
||||
_unwrap_keys: list[str] = []
|
||||
if isinstance(unwrapped, dict):
|
||||
_unwrap_keys.extend(str(k) for k in unwrapped.keys())
|
||||
if isinstance(unwrapped, dict):
|
||||
_t_preview = ""
|
||||
_t_len = 0
|
||||
if isinstance(token, str) and token.strip():
|
||||
_t_len = len(token.strip())
|
||||
if _t_len > 8:
|
||||
_t_preview = token.strip()[:8] + "..."
|
||||
else:
|
||||
_t_preview = token.strip()
|
||||
_fmt_note = ""
|
||||
if _t_len >= 120:
|
||||
_fmt_note = " [JWT-like]"
|
||||
elif _t_len > 0:
|
||||
_fmt_note = " [SHORT]"
|
||||
_wrap_layer = "ROOT"
|
||||
if isinstance(payload, dict):
|
||||
for _wk in ("result", "data", "payload"):
|
||||
if isinstance(payload.get(_wk), dict):
|
||||
_wrap_layer = "result/data/payload"
|
||||
break
|
||||
_all_keys_s = ",".join(sorted(_all_keys)) if _all_keys else "-"
|
||||
_unwrap_keys_s = ",".join(sorted(_unwrap_keys)) if _unwrap_keys else "-"
|
||||
_t_info = "MISSING"
|
||||
if _t_len:
|
||||
_t_info = "YES len=" + str(_t_len) + " head=" + _t_preview
|
||||
msg = f"[INJECT-LOGIN] ✅ login OK: wrapLayer={_wrap_layer} topKeys={_all_keys_s} unwrappedKeys={_unwrap_keys_s} token={_t_info}{_fmt_note}"
|
||||
app_logging.logger.info("%s", msg)
|
||||
return token, unwrapped
|
||||
# Fallback: non-dict payload (string/list/number) — still log it, but no token expected.
|
||||
app_logging.logger.info(
|
||||
"[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.",
|
||||
type(payload).__name__,
|
||||
)
|
||||
if settings.S2S_DEBUG_PROMPT:
|
||||
app_logging.logger.info(
|
||||
"[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n",
|
||||
len(str(payload)),
|
||||
payload,
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | None = None) -> Any:
|
||||
"""Fetch the latest caller summary via S2S_LATEST_SUMMARY URL.
|
||||
|
||||
Generic / completely deploy-agnostic: plain GET
|
||||
S2S_LATEST_SUMMARY?phoneNumber=<phone>; accepts JSON (optional {"result": …}
|
||||
wrapper) or plain text. Never fails the proxy / reasoning; returns None on
|
||||
any error and logs it. If `auth_token` is provided it is sent as
|
||||
Authorization: Bearer <auth_token>.
|
||||
|
||||
OVERRIDE (2026-09-23): when S2S_LATEST_SUMMARY_TEXT is set (non-empty) it
|
||||
takes absolute priority over S2S_LATEST_SUMMARY. No HTTP call is made at all;
|
||||
the raw plain-text value is returned directly. Designed for operators who want
|
||||
a clean, pre-curated, essential summary snippet without any upstream API
|
||||
dependency. Works even without a phone_number (static snippet).
|
||||
"""
|
||||
if settings.S2S_LATEST_SUMMARY_TEXT:
|
||||
override_len = len(settings.S2S_LATEST_SUMMARY_TEXT)
|
||||
app_logging.logger.info(
|
||||
"[INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len=%d chars). Skipping HTTP fetch; injecting raw static plain-text summary directly.",
|
||||
override_len,
|
||||
)
|
||||
if settings.S2S_DEBUG_PROMPT:
|
||||
app_logging.logger.info(
|
||||
"[INJECT-SUMMARY] [DEBUG-PROMPT] S2S_LATEST_SUMMARY_TEXT raw value (first 800 chars):\n%s",
|
||||
settings.S2S_LATEST_SUMMARY_TEXT[:800],
|
||||
)
|
||||
return settings.S2S_LATEST_SUMMARY_TEXT
|
||||
if not settings.S2S_LATEST_SUMMARY or not phone_number:
|
||||
return None
|
||||
target_url = f"{settings.S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}"
|
||||
app_logging.logger.info(
|
||||
"[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s",
|
||||
settings.S2S_LATEST_SUMMARY,
|
||||
len(phone_number),
|
||||
"yes" if auth_token else "no",
|
||||
)
|
||||
try:
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
||||
resp = await http.get(target_url, headers=headers or None)
|
||||
if resp.status_code != 200:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-SUMMARY] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
|
||||
resp.status_code,
|
||||
len(phone_number),
|
||||
resp.text,
|
||||
)
|
||||
return None
|
||||
# Accept JSON first, then fall back to plain text (some summary APIs
|
||||
# return text/plain).
|
||||
text = (resp.text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
app_logging.logger.info(
|
||||
"[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.",
|
||||
len(text),
|
||||
)
|
||||
return text
|
||||
except Exception as exc:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-SUMMARY] ⛔ fetch unreachable phoneLen=%d error=%r",
|
||||
len(phone_number),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
# Unwrap {"result": …} just like profile fetch (common wrapper pattern).
|
||||
if isinstance(payload, dict) and "result" in payload:
|
||||
payload = payload["result"]
|
||||
# Filter out empty dict/list / empty string after unwrap.
|
||||
empty = (
|
||||
payload is None
|
||||
or (isinstance(payload, str) and not payload.strip())
|
||||
or (isinstance(payload, dict) and len(payload) == 0)
|
||||
or (isinstance(payload, list) and len(payload) == 0)
|
||||
)
|
||||
if empty:
|
||||
app_logging.logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.")
|
||||
return None
|
||||
if isinstance(payload, dict):
|
||||
app_logging.logger.info(
|
||||
"[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s",
|
||||
",".join(sorted(str(k) for k in payload.keys())),
|
||||
)
|
||||
elif isinstance(payload, list):
|
||||
app_logging.logger.info(
|
||||
"[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d",
|
||||
len(payload),
|
||||
)
|
||||
else:
|
||||
app_logging.logger.info(
|
||||
"[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d",
|
||||
len(str(payload)),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def _fetch_podcast_episode(episode_id: str) -> Any:
|
||||
"""Fetch a single podcast episode by ID from the Aquachile AzurianLab API.
|
||||
|
||||
Completely generic / deploy-agnostic: plain GET
|
||||
https://aquachile.azurianlab.com/api/v1/podcast/episodes/<episode_id>
|
||||
Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails
|
||||
the proxy / reasoning; returns None on any error and logs it.
|
||||
"""
|
||||
if not episode_id:
|
||||
return None
|
||||
target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}"
|
||||
app_logging.logger.info(
|
||||
"[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s",
|
||||
target_url,
|
||||
episode_id,
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
||||
resp = await http.get(target_url)
|
||||
if resp.status_code != 200:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s",
|
||||
resp.status_code,
|
||||
episode_id,
|
||||
resp.text,
|
||||
)
|
||||
return None
|
||||
text = (resp.text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
app_logging.logger.info(
|
||||
"[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.",
|
||||
len(text),
|
||||
)
|
||||
return text
|
||||
except Exception as exc:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r",
|
||||
episode_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
if isinstance(payload, dict) and "result" in payload:
|
||||
payload = payload["result"]
|
||||
empty = (
|
||||
payload is None
|
||||
or (isinstance(payload, str) and not payload.strip())
|
||||
or (isinstance(payload, dict) and len(payload) == 0)
|
||||
or (isinstance(payload, list) and len(payload) == 0)
|
||||
)
|
||||
if empty:
|
||||
app_logging.logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.")
|
||||
return None
|
||||
if isinstance(payload, dict):
|
||||
app_logging.logger.info(
|
||||
"[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s",
|
||||
",".join(sorted(str(k) for k in payload.keys())),
|
||||
)
|
||||
elif isinstance(payload, list):
|
||||
app_logging.logger.info(
|
||||
"[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d",
|
||||
len(payload),
|
||||
)
|
||||
else:
|
||||
app_logging.logger.info(
|
||||
"[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d",
|
||||
len(str(payload)),
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None:
|
||||
"""Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone.
|
||||
Returns the raw {"result": ...}-unwrapped object or None. Errors are logged
|
||||
then suppressed (never fail the proxy / reasoning because of this).
|
||||
|
||||
When `auth_token` is provided it is forwarded as Authorization: Bearer <auth_token>
|
||||
to the upstream profile endpoint.
|
||||
"""
|
||||
if not settings.VOCERO_USER_PHONE_AUTH_URL or not phone_number:
|
||||
return None
|
||||
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}"
|
||||
app_logging.logger.info(
|
||||
"[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s",
|
||||
settings.VOCERO_USER_PHONE_AUTH_URL,
|
||||
len(phone_number),
|
||||
"yes" if auth_token else "no",
|
||||
)
|
||||
try:
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
||||
resp = await http.get(target_url, headers=headers or None)
|
||||
if resp.status_code != 200:
|
||||
app_logging.logger.info(
|
||||
"[INJECT-PROFILE] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
|
||||
resp.status_code,
|
||||
len(phone_number),
|
||||
resp.text,
|
||||
)
|
||||
return None
|
||||
payload = resp.json()
|
||||
except Exception as exc:
|
||||
app_logging.logger.warning(
|
||||
"[INJECT-PROFILE] ⛔ fetch unreachable phoneLen=%d error=%r",
|
||||
len(phone_number),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
if isinstance(payload, dict) and "result" in payload and isinstance(payload["result"], dict):
|
||||
profile_obj = payload["result"]
|
||||
elif isinstance(payload, dict) and not isinstance(payload, list):
|
||||
profile_obj = payload
|
||||
else:
|
||||
app_logging.logger.info(
|
||||
"[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?",
|
||||
type(payload).__name__,
|
||||
)
|
||||
return None
|
||||
app_logging.logger.info(
|
||||
"[INJECT-PROFILE] ✅ profile loaded OK keys=%s",
|
||||
",".join(sorted(profile_obj.keys())),
|
||||
)
|
||||
return profile_obj
|
||||
Reference in New Issue
Block a user