From ed56ddd2b51184877fad115d9c5ce390ba6092ae Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:02:10 -0600 Subject: [PATCH 01/15] Add Alexa skill integration as voice-to-Telegram relay Alexa requests are received at /alexa, validated (cert chain, timestamp, skill ID), and relayed as synthetic Telegram webhooks. Includes full cryptographic signature verification when the cryptography library is available, with graceful degradation to header+timestamp checks. Also adds a progressive fallback in telegram_send_message: if reply_to fails (e.g. for synthetic Alexa message IDs), retries without it. --- lib/server.py | 272 ++++++++++++++++++++++++++++++++++++++++++++++-- lib/telegram.sh | 12 ++- 2 files changed, 273 insertions(+), 11 deletions(-) diff --git a/lib/server.py b/lib/server.py index 0600c49..0a41e8c 100644 --- a/lib/server.py +++ b/lib/server.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import base64 +import hashlib import hmac import json import os @@ -12,6 +14,7 @@ import urllib.request import urllib.error from collections import deque, OrderedDict +from datetime import datetime, timezone from http.server import HTTPServer, BaseHTTPRequestHandler from socketserver import ThreadingMixIn @@ -33,6 +36,7 @@ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "") +ALEXA_SKILL_ID = os.environ.get("ALEXA_SKILL_ID", "") # Per-chat message queues for serial processing chat_queues = {} # chat_id -> deque of webhook bodies @@ -189,6 +193,18 @@ def _respond(self, code, data): def log_message(self, format, *args): sys.stderr.write("[%s] [http] %s\n" % (self.log_date_time_string(), format % args)) + def _read_body(self): + """Read and return the request body, or None on error.""" + try: + length = int(self.headers.get("Content-Length", 0)) + except (ValueError, TypeError): + self._respond(400, {"error": "invalid content-length"}) + return None + if length > MAX_BODY_SIZE: + self._respond(413, {"error": "payload too large"}) + return None + return self.rfile.read(length).decode("utf-8", errors="replace") if length else "" + def do_POST(self): if self.path == "/telegram/webhook": # Reject early during shutdown so Telegram retries later @@ -200,20 +216,123 @@ def do_POST(self): if not hmac.compare_digest(token, WEBHOOK_SECRET): self._respond(401, {"error": "unauthorized"}) return - try: - length = int(self.headers.get("Content-Length", 0)) - except (ValueError, TypeError): - self._respond(400, {"error": "invalid content-length"}) + body = self._read_body() + if body is None: return - if length > MAX_BODY_SIZE: - self._respond(413, {"error": "payload too large"}) - return - body = self.rfile.read(length).decode("utf-8", errors="replace") if length else "" self._respond(200, {"ok": True}) enqueue_webhook(body) + elif self.path == "/alexa": + self._handle_alexa() else: self._respond(404, {"error": "not found"}) + def _handle_alexa(self): + """Handle Alexa skill requests — async relay to Telegram.""" + if shutting_down: + self._respond_alexa("Lo siento, estoy reiniciando. Intenta en un momento.", end_session=True) + return + + body = self._read_body() + if body is None: + return + + # Validate the request comes from Alexa + if not _verify_alexa_request(self.headers, body): + self._respond(401, {"error": "invalid alexa request"}) + return + + try: + data = json.loads(body) + except json.JSONDecodeError: + self._respond(400, {"error": "invalid json"}) + return + + # Validate skill ID if configured + app_id = data.get("session", {}).get("application", {}).get("applicationId", "") + if ALEXA_SKILL_ID and app_id != ALEXA_SKILL_ID: + self._respond(401, {"error": "skill id mismatch"}) + return + + req_type = data.get("request", {}).get("type", "") + intent_name = data.get("request", {}).get("intent", {}).get("name", "") + sys.stderr.write("[alexa] req_type=%s intent=%s session_new=%s\n" % + (req_type, intent_name or "-", + data.get("session", {}).get("new"))) + sys.stderr.write("[alexa] full_request: %s\n" % json.dumps(data, ensure_ascii=False)) + + if req_type == "LaunchRequest": + self._respond_alexa("Dime qué le quieres decir a Claudio.", end_session=False) + return + + if req_type == "SessionEndedRequest": + self._respond_alexa("", end_session=True) + return + + if req_type == "IntentRequest": + intent = data.get("request", {}).get("intent", {}) + intent_name = intent.get("name", "") + + # Built-in intents + if intent_name in ("AMAZON.CancelIntent", "AMAZON.StopIntent", "AMAZON.NoIntent"): + self._respond_alexa("Adiós.", end_session=True) + return + if intent_name == "AMAZON.HelpIntent": + self._respond_alexa( + "Puedes decirme cualquier mensaje y se lo paso a Claudio por Telegram.", + end_session=False, + ) + return + if intent_name == "AMAZON.FallbackIntent": + self._respond_alexa( + "No entendí. Intenta decir: dile a Claudio, seguido de tu mensaje.", + end_session=False, + ) + return + + # Our custom intent: relay message to Claudio + if intent_name == "SendMessageIntent": + message = intent.get("slots", {}).get("message", {}).get("value", "") + if not message: + self._respond_alexa("No escuché el mensaje. Intenta de nuevo.", end_session=False) + return + + # Create synthetic Telegram webhook and enqueue it + _enqueue_alexa_message(message) + self._respond_alexa("Ok, le paso el mensaje. ¿Algo más?", end_session=False, reprompt="¿Algo más para Claudio?") + return + + # Unknown request type + sys.stderr.write("[alexa] unhandled: req_type=%s intent=%s\n" % (req_type, intent_name)) + self._respond_alexa("No entendí la solicitud.", end_session=True) + + def _respond_alexa(self, text, end_session=True, reprompt=None): + """Send an Alexa-formatted JSON response.""" + response = { + "version": "1.0", + "response": { + "shouldEndSession": end_session, + }, + } + if text: + response["response"]["outputSpeech"] = { + "type": "PlainText", + "text": text, + } + if reprompt: + response["response"]["reprompt"] = { + "outputSpeech": { + "type": "PlainText", + "text": reprompt, + } + } + body = json.dumps(response).encode("utf-8") + sys.stderr.write("[alexa] response: %s\n" % body.decode()) + self.send_response(200) + self.send_header("Content-Type", "application/json;charset=UTF-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_GET(self): if self.path == "/health": health = check_health() @@ -223,6 +342,143 @@ def do_GET(self): self._respond(404, {"error": "not found"}) +_alexa_update_counter = 0 +_alexa_counter_lock = threading.Lock() + + +def _enqueue_alexa_message(message): + """Create a synthetic Telegram webhook body from an Alexa message and enqueue it.""" + global _alexa_update_counter + with _alexa_counter_lock: + _alexa_update_counter += 1 + update_id = 900000000 + _alexa_update_counter + + body = json.dumps({ + "update_id": update_id, + "message": { + "message_id": update_id, + "date": int(time.time()), + "chat": {"id": int(TELEGRAM_CHAT_ID), "type": "private"}, + "from": {"id": int(TELEGRAM_CHAT_ID), "first_name": "Alexa", "is_bot": False}, + "text": f"[Mensaje por voz desde Alexa]\n\n{message}", + }, + }) + enqueue_webhook(body) + + +def _verify_alexa_request(headers, body): + """Verify that the request comes from Alexa by validating the certificate chain. + + Amazon requires signature verification for production skills. For dev/testing + mode, we do basic validation of the signature headers and timestamp. + Full certificate chain validation requires the cryptography library. + """ + # Check required headers exist + # Alexa sends: SignatureCertChainUrl and Signature-256 (or Signature) + # HTTP proxies may lowercase header names, so check case-insensitively + cert_url = headers.get("SignatureCertChainUrl", "") or headers.get("Signaturecertchainurl", "") + signature = headers.get("Signature-256", "") or headers.get("Signature", "") + + if not cert_url or not signature: + # Log all headers for debugging + sys.stderr.write(f"[alexa] Missing signature headers. Received headers: {dict(headers)}\n") + return False + + # Validate cert URL (must be Amazon's domain, HTTPS, port 443, path starts with /echo.api/) + try: + from urllib.parse import urlparse + parsed = urlparse(cert_url) + if parsed.scheme.lower() != "https": + sys.stderr.write(f"[alexa] Cert URL scheme not HTTPS: {cert_url}\n") + return False + if parsed.hostname.lower() != "s3.amazonaws.com": + sys.stderr.write(f"[alexa] Cert URL hostname invalid: {parsed.hostname}\n") + return False + if not parsed.path.startswith("/echo.api/"): + sys.stderr.write(f"[alexa] Cert URL path invalid: {parsed.path}\n") + return False + if parsed.port is not None and parsed.port != 443: + sys.stderr.write(f"[alexa] Cert URL port invalid: {parsed.port}\n") + return False + except Exception as e: + sys.stderr.write(f"[alexa] Cert URL parse error: {e}\n") + return False + + # Validate timestamp (within 150 seconds) + try: + data = json.loads(body) + timestamp = data.get("request", {}).get("timestamp", "") + if timestamp: + req_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + delta = abs((now - req_time).total_seconds()) + if delta > 150: + sys.stderr.write(f"[alexa] Timestamp too old: {delta}s\n") + return False + except (json.JSONDecodeError, ValueError) as e: + sys.stderr.write(f"[alexa] Timestamp validation error: {e}\n") + return False + + # Full certificate signature verification + try: + return _verify_alexa_signature(cert_url, signature, body) + except ImportError: + # cryptography library not installed — fall back to header+timestamp checks only + sys.stderr.write("[alexa] cryptography library not available, skipping signature verification\n") + return True + except Exception as e: + sys.stderr.write(f"[alexa] Signature verification error: {e}\n") + return False + + +# Cache for downloaded Alexa signing certificates +_alexa_cert_cache = {} +_ALEXA_CERT_CACHE_TTL = 3600 # 1 hour + + +def _verify_alexa_signature(cert_url, signature_b64, body): + """Full cryptographic verification of Alexa request signature.""" + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + + # Get or fetch the signing certificate + now = time.monotonic() + cached = _alexa_cert_cache.get(cert_url) + if cached and (now - cached["time"]) < _ALEXA_CERT_CACHE_TTL: + cert = cached["cert"] + else: + req = urllib.request.Request(cert_url) + with urllib.request.urlopen(req, timeout=10) as resp: + pem_data = resp.read() + cert = x509.load_pem_x509_certificate(pem_data) + + # Validate the certificate's Subject Alternative Name includes echo-api.amazon.com + try: + san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + dns_names = san.value.get_values_for_type(x509.DNSName) + if "echo-api.amazon.com" not in dns_names: + sys.stderr.write(f"[alexa] Certificate SAN missing echo-api.amazon.com: {dns_names}\n") + return False + except x509.ExtensionNotFound: + sys.stderr.write("[alexa] Certificate missing SAN extension\n") + return False + + _alexa_cert_cache[cert_url] = {"cert": cert, "time": now} + + # Verify the signature + signature_bytes = base64.b64decode(signature_b64) + public_key = cert.public_key() + public_key.verify( + signature_bytes, + body.encode("utf-8"), + padding.PKCS1v15(), + hashes.SHA256(), + ) + + return True + + def check_health(): """Verify system health by checking Telegram webhook status.""" now = time.monotonic() diff --git a/lib/telegram.sh b/lib/telegram.sh index 0a44f37..239739f 100644 --- a/lib/telegram.sh +++ b/lib/telegram.sh @@ -92,11 +92,11 @@ telegram_send_message() { local result result=$(telegram_api "sendMessage" "${args[@]}") - # If markdown fails, retry without parse_mode + # If send fails, retry with progressively fewer options local ok ok=$(echo "$result" | jq -r '.ok // empty' 2>/dev/null) if [ "$ok" != "true" ]; then - # Rebuild args without parse_mode + # Retry without parse_mode (keeps reply_to) args=(-d "chat_id=${chat_id}" --data-urlencode "text=${chunk}") if [ "$should_reply" = true ]; then args+=(-d "reply_to_message_id=${reply_to_message_id}") @@ -104,7 +104,13 @@ telegram_send_message() { result=$(telegram_api "sendMessage" "${args[@]}") || true ok=$(echo "$result" | jq -r '.ok // empty' 2>/dev/null) if [ "$ok" != "true" ]; then - log_error "telegram" "Failed to send message after markdown fallback for chat $chat_id" + # Retry without reply_to (e.g. synthetic Alexa message_ids) + args=(-d "chat_id=${chat_id}" --data-urlencode "text=${chunk}") + result=$(telegram_api "sendMessage" "${args[@]}") || true + ok=$(echo "$result" | jq -r '.ok // empty' 2>/dev/null) + if [ "$ok" != "true" ]; then + log_error "telegram" "Failed to send message after all fallbacks for chat $chat_id" + fi fi fi done From f4261281bd41caedeae93c328a583b9b9dbf7085 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:25:39 -0600 Subject: [PATCH 02/15] Document Alexa integration with security warnings and setup guide Add critical security warning in the CAUTION section explaining the higher risk profile of the Alexa integration vs Telegram-only setup. Add full Alexa section under Customization with how-it-works flow, step-by-step setup instructions, and security considerations. Add ALEXA_SKILL_ID to the environment variables reference. --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index c60cdef..4f48233 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ The whole purpose of Claudio is to give you remote access to Claude Code from yo Since there's no human in front of the terminal to approve permission prompts, Claude Code must run autonomously. Rather than using `--dangerously-skip-permissions`, Claudio explicitly lists the tools Claude can use and auto-approves them — excluding interactive-only tools like `AskUserQuestion` and `Chrome`. Claudio mitigates the risk through: webhook secret validation (HMAC), single authorized `chat_id`, and binding the HTTP server to localhost only (external access goes through cloudflared). +> **⚠️ Alexa Integration (Optional) — Higher Security Risk** +> +> The optional Alexa skill integration exposes an additional `/alexa` endpoint that accepts voice commands and relays them to Claude Code via Telegram. This carries a *higher security risk* than the Telegram-only setup because: (1) anyone with physical access to your Alexa device can send commands to Claude Code — there is no per-user authentication beyond Amazon's skill ID validation, (2) if the `cryptography` Python library is not installed, signature verification falls back to timestamp-only checks, and (3) unlike Telegram (which binds to a single `chat_id`), the Alexa endpoint relies on the skill remaining private (unpublished) to limit access. **Do not enable Alexa integration unless you understand these risks.** See the [Alexa](#alexa) section for setup instructions. + ### Requirements - Claude Code CLI (with Pro/Max subscription) @@ -175,6 +179,52 @@ You can send documents (PDF, text files, CSV, code files, etc.) to Claudio. Incl - Claude reads the file directly from disk — text-based formats (PDF, CSV, code, plain text) work best; binary files may produce limited results - Files are stored temporarily during processing, then deleted immediately after Claude responds +### Alexa + +> **This integration is optional and carries additional security risks.** See the [security warning](#caution-security-risk) above before enabling. + +Claudio can receive voice commands through an Amazon Alexa skill. When you speak to Alexa, the message is relayed to Claude Code via the same Telegram pipeline — Claude's response appears in your Telegram chat. + +**How it works:** + +1. You say: _"Alexa, abre Claudio"_ → Alexa opens the skill +2. You say your message → Alexa sends it to the `/alexa` endpoint +3. Claudio relays it to Claude Code as a synthetic Telegram message +4. Claude's response appears in your Telegram chat +5. Alexa asks _"¿Algo más?"_ — you can send another message or say _"No"_ to end + +**Setup:** + +1. Install the `cryptography` Python library (strongly recommended): + +```bash +pip3 install cryptography +``` + +2. Create a custom Alexa skill at [developer.amazon.com](https://developer.amazon.com/alexa/console/ask): + - Invocation name: `claudio` (or your preferred name) + - Endpoint: HTTPS, URL: `https:///alexa` + - SSL certificate type: _"My development endpoint is a sub-domain of a domain that has a wildcard certificate from a certificate authority"_ + - Create a custom intent `SendMessageIntent` with a slot `message` of type `AMAZON.SearchQuery` + - Add sample utterances: `dile {message}`, `dile a claudio {message}`, `{message}` + - Enable built-in intents: `AMAZON.CancelIntent`, `AMAZON.StopIntent`, `AMAZON.HelpIntent`, `AMAZON.FallbackIntent` + +3. Copy the skill ID and add it to your config: + +```bash +echo 'ALEXA_SKILL_ID="amzn1.ask.skill.YOUR-SKILL-ID"' >> ~/.claudio/service.env +claudio restart +``` + +4. Keep the skill in **development mode** (do not publish it) to restrict access to your Amazon account only. + +**Security considerations:** + +- Without `cryptography`, signature verification falls back to timestamp + header checks only (no certificate chain validation) +- Anyone with physical access to your Alexa device can send commands — there is no voice PIN or per-user auth +- Setting `ALEXA_SKILL_ID` is strongly recommended to reject requests from other skills +- Alexa messages appear in Telegram prefixed with `[Mensaje por voz desde Alexa]` so you can distinguish the source + ### Parallel Work Parallel work (reviews, research, etc.) is handled by Claude Code's built-in Task tool (subagents). No custom agent infrastructure is needed — Claude natively spawns subagents, manages their lifecycle, and collects results within a single `claude -p` invocation. @@ -233,6 +283,10 @@ The following variables can be set in `$HOME/.claudio/service.env`: - `WEBHOOK_SECRET` — HMAC secret for validating incoming webhook requests. Auto-generated on first run if not set. - `WEBHOOK_RETRY_DELAY` — Seconds between webhook registration retry attempts. Default: `60`. +**Alexa (Optional)** + +- `ALEXA_SKILL_ID` — Amazon Alexa skill application ID. If set, only requests from this skill are accepted. Strongly recommended for security. + **Voice (TTS/STT)** - `ELEVENLABS_API_KEY` — API key for ElevenLabs. Required for voice messages (both TTS and STT). @@ -300,6 +354,7 @@ bats tests/db.bats - [x] Parallel work via Claude Code's built-in Task tool (subagents) - [x] Cognitive memory system (ACT-R activation scoring, embedding-based retrieval) - [x] Automated backup system (hourly/daily rotating backups with rsync) +- [x] Alexa skill integration (optional voice-to-Telegram relay) **Future** From 37d0c0606339e087ffe26d2beed3cdb4656d7809 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:20:24 -0600 Subject: [PATCH 03/15] Address review feedback: fix auth bypass, remove PII logging, harden cert validation - Return False when cryptography library is missing instead of bypassing signature verification (P0 security fix) - Remove full request/response body logging to prevent PII leakage - Add TELEGRAM_CHAT_ID guard in _enqueue_alexa_message - Validate certificate expiry before caching - Use only Signature-256 header (SHA-256), drop deprecated SHA-1 fallback - Use top-level urllib.parse instead of local import - Remove unused hashlib import - Replace raw header dump with safe presence-only log --- lib/server.py | 49 ++++++++++++++++++++----------------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/lib/server.py b/lib/server.py index 0a41e8c..34f4c0d 100644 --- a/lib/server.py +++ b/lib/server.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import base64 -import hashlib import hmac import json import os @@ -20,7 +19,6 @@ MAX_BODY_SIZE = 1024 * 1024 # 1 MB MAX_QUEUE_SIZE = 5 # Max queued messages per chat -WEBHOOK_TIMEOUT = 600 # 10 minutes max per Claude invocation HEALTH_CACHE_TTL = 30 # seconds between health check API calls QUEUE_WARNING_RATIO = 0.8 # Warn when queue reaches this fraction of max @@ -103,26 +101,12 @@ def _process_queue_loop(chat_id): env=env, start_new_session=True, ) - proc.communicate(input=body.encode(), timeout=WEBHOOK_TIMEOUT) + proc.communicate(input=body.encode()) if proc.returncode != 0: sys.stderr.write( f"[queue] Webhook handler exited with code {proc.returncode} " f"for chat {chat_id}\n" ) - except subprocess.TimeoutExpired: - if proc is not None: - try: - os.killpg(proc.pid, signal.SIGTERM) - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=30) - except OSError: - try: - proc.wait(timeout=30) - except Exception: - pass - sys.stderr.write(f"[queue] Timeout processing message for chat {chat_id}\n") except Exception as e: sys.stderr.write(f"[queue] Error processing message for chat {chat_id}: {e}\n") time.sleep(1) # Avoid tight loop on persistent errors @@ -258,7 +242,6 @@ def _handle_alexa(self): sys.stderr.write("[alexa] req_type=%s intent=%s session_new=%s\n" % (req_type, intent_name or "-", data.get("session", {}).get("new"))) - sys.stderr.write("[alexa] full_request: %s\n" % json.dumps(data, ensure_ascii=False)) if req_type == "LaunchRequest": self._respond_alexa("Dime qué le quieres decir a Claudio.", end_session=False) @@ -326,7 +309,7 @@ def _respond_alexa(self, text, end_session=True, reprompt=None): } } body = json.dumps(response).encode("utf-8") - sys.stderr.write("[alexa] response: %s\n" % body.decode()) + sys.stderr.write("[alexa] response: end_session=%s text_len=%d\n" % (end_session, len(text))) self.send_response(200) self.send_header("Content-Type", "application/json;charset=UTF-8") self.send_header("Content-Length", str(len(body))) @@ -348,6 +331,9 @@ def do_GET(self): def _enqueue_alexa_message(message): """Create a synthetic Telegram webhook body from an Alexa message and enqueue it.""" + if not TELEGRAM_CHAT_ID: + sys.stderr.write("[alexa] TELEGRAM_CHAT_ID not configured, cannot relay message\n") + return global _alexa_update_counter with _alexa_counter_lock: _alexa_update_counter += 1 @@ -376,18 +362,17 @@ def _verify_alexa_request(headers, body): # Check required headers exist # Alexa sends: SignatureCertChainUrl and Signature-256 (or Signature) # HTTP proxies may lowercase header names, so check case-insensitively - cert_url = headers.get("SignatureCertChainUrl", "") or headers.get("Signaturecertchainurl", "") - signature = headers.get("Signature-256", "") or headers.get("Signature", "") + cert_url = headers.get("SignatureCertChainUrl", "") + signature = headers.get("Signature-256", "") if not cert_url or not signature: - # Log all headers for debugging - sys.stderr.write(f"[alexa] Missing signature headers. Received headers: {dict(headers)}\n") + sys.stderr.write("[alexa] Missing signature headers: SignatureCertChainUrl=%s Signature-256=%s\n" % + ("present" if cert_url else "missing", "present" if signature else "missing")) return False # Validate cert URL (must be Amazon's domain, HTTPS, port 443, path starts with /echo.api/) try: - from urllib.parse import urlparse - parsed = urlparse(cert_url) + parsed = urllib.parse.urlparse(cert_url) if parsed.scheme.lower() != "https": sys.stderr.write(f"[alexa] Cert URL scheme not HTTPS: {cert_url}\n") return False @@ -423,9 +408,9 @@ def _verify_alexa_request(headers, body): try: return _verify_alexa_signature(cert_url, signature, body) except ImportError: - # cryptography library not installed — fall back to header+timestamp checks only - sys.stderr.write("[alexa] cryptography library not available, skipping signature verification\n") - return True + # cryptography library not installed — fail securely + sys.stderr.write("[alexa] cryptography library not available, rejecting request\n") + return False except Exception as e: sys.stderr.write(f"[alexa] Signature verification error: {e}\n") return False @@ -453,6 +438,12 @@ def _verify_alexa_signature(cert_url, signature_b64, body): pem_data = resp.read() cert = x509.load_pem_x509_certificate(pem_data) + # Validate certificate is currently valid + now_utc = datetime.now(timezone.utc) + if now_utc < cert.not_valid_before_utc or now_utc > cert.not_valid_after_utc: + sys.stderr.write("[alexa] Certificate is expired or not yet valid\n") + return False + # Validate the certificate's Subject Alternative Name includes echo-api.amazon.com try: san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) @@ -598,7 +589,7 @@ def _graceful_shutdown(server, shutdown_event): if threads_to_wait: sys.stderr.write(f"[shutdown] Waiting for {len(threads_to_wait)} active handler(s)...\n") for t in threads_to_wait: - t.join(timeout=WEBHOOK_TIMEOUT + 10) + t.join(timeout=300) # Wait up to 5 min for handler to finish on shutdown if t.is_alive(): sys.stderr.write(f"[shutdown] WARNING: thread {t.name} still alive after timeout\n") sys.stderr.write("[shutdown] All handlers finished, exiting cleanly.\n") From a9720a9fe7a2bc3225fcd1fd537698380ddb42fc Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:35:48 -0600 Subject: [PATCH 04/15] Format Alexa message with italics in code instead of relying on model behavior The Alexa voice query prefix and quoting is now applied in _enqueue_alexa_message so the formatting is deterministic. --- lib/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server.py b/lib/server.py index 34f4c0d..5b4959f 100644 --- a/lib/server.py +++ b/lib/server.py @@ -346,7 +346,7 @@ def _enqueue_alexa_message(message): "date": int(time.time()), "chat": {"id": int(TELEGRAM_CHAT_ID), "type": "private"}, "from": {"id": int(TELEGRAM_CHAT_ID), "first_name": "Alexa", "is_bot": False}, - "text": f"[Mensaje por voz desde Alexa]\n\n{message}", + "text": f'_[Alexa voice query]:_ "{message}"', }, }) enqueue_webhook(body) From 88e036458474249d019cf44beb32379e5448fea8 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:38:17 -0600 Subject: [PATCH 05/15] Restore webhook handler timeout to prevent indefinite thread blocking Reintroduces WEBHOOK_TIMEOUT (600s) on proc.communicate() that was accidentally removed in 99ccaa7. Without it, a hung Claude process blocks the per-chat queue thread forever. Now properly kills the process group (SIGTERM then SIGKILL) on timeout. --- lib/server.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/server.py b/lib/server.py index 5b4959f..3c0aecd 100644 --- a/lib/server.py +++ b/lib/server.py @@ -19,6 +19,7 @@ MAX_BODY_SIZE = 1024 * 1024 # 1 MB MAX_QUEUE_SIZE = 5 # Max queued messages per chat +WEBHOOK_TIMEOUT = 600 # 10 minutes max per Claude invocation HEALTH_CACHE_TTL = 30 # seconds between health check API calls QUEUE_WARNING_RATIO = 0.8 # Warn when queue reaches this fraction of max @@ -101,12 +102,23 @@ def _process_queue_loop(chat_id): env=env, start_new_session=True, ) - proc.communicate(input=body.encode()) + proc.communicate(input=body.encode(), timeout=WEBHOOK_TIMEOUT) if proc.returncode != 0: sys.stderr.write( f"[queue] Webhook handler exited with code {proc.returncode} " f"for chat {chat_id}\n" ) + except subprocess.TimeoutExpired: + sys.stderr.write( + f"[queue] Webhook handler timed out after {WEBHOOK_TIMEOUT}s " + f"for chat {chat_id}, killing process\n" + ) + if proc: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except Exception as e: sys.stderr.write(f"[queue] Error processing message for chat {chat_id}: {e}\n") time.sleep(1) # Avoid tight loop on persistent errors @@ -589,7 +601,7 @@ def _graceful_shutdown(server, shutdown_event): if threads_to_wait: sys.stderr.write(f"[shutdown] Waiting for {len(threads_to_wait)} active handler(s)...\n") for t in threads_to_wait: - t.join(timeout=300) # Wait up to 5 min for handler to finish on shutdown + t.join(timeout=WEBHOOK_TIMEOUT + 10) if t.is_alive(): sys.stderr.write(f"[shutdown] WARNING: thread {t.name} still alive after timeout\n") sys.stderr.write("[shutdown] All handlers finished, exiting cleanly.\n") From 7b9d24c110180c0118e066e6c63c7073466f5f8e Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:54:48 -0600 Subject: [PATCH 06/15] Localize Alexa responses based on request locale Extract request.locale from Alexa requests and use it to serve responses in the user's language. Supports Spanish and English with English as fallback for unsupported locales. --- lib/server.py | 64 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/lib/server.py b/lib/server.py index 3c0aecd..0222be9 100644 --- a/lib/server.py +++ b/lib/server.py @@ -225,7 +225,7 @@ def do_POST(self): def _handle_alexa(self): """Handle Alexa skill requests — async relay to Telegram.""" if shutting_down: - self._respond_alexa("Lo siento, estoy reiniciando. Intenta en un momento.", end_session=True) + self._respond_alexa(_alexa_str("en", "shutting_down"), end_session=True) return body = self._read_body() @@ -249,14 +249,15 @@ def _handle_alexa(self): self._respond(401, {"error": "skill id mismatch"}) return + locale = data.get("request", {}).get("locale", "en-US") req_type = data.get("request", {}).get("type", "") intent_name = data.get("request", {}).get("intent", {}).get("name", "") - sys.stderr.write("[alexa] req_type=%s intent=%s session_new=%s\n" % - (req_type, intent_name or "-", + sys.stderr.write("[alexa] req_type=%s intent=%s locale=%s session_new=%s\n" % + (req_type, intent_name or "-", locale, data.get("session", {}).get("new"))) if req_type == "LaunchRequest": - self._respond_alexa("Dime qué le quieres decir a Claudio.", end_session=False) + self._respond_alexa(_alexa_str(locale, "launch"), end_session=False) return if req_type == "SessionEndedRequest": @@ -269,36 +270,34 @@ def _handle_alexa(self): # Built-in intents if intent_name in ("AMAZON.CancelIntent", "AMAZON.StopIntent", "AMAZON.NoIntent"): - self._respond_alexa("Adiós.", end_session=True) + self._respond_alexa(_alexa_str(locale, "goodbye"), end_session=True) return if intent_name == "AMAZON.HelpIntent": - self._respond_alexa( - "Puedes decirme cualquier mensaje y se lo paso a Claudio por Telegram.", - end_session=False, - ) + self._respond_alexa(_alexa_str(locale, "help"), end_session=False) return if intent_name == "AMAZON.FallbackIntent": - self._respond_alexa( - "No entendí. Intenta decir: dile a Claudio, seguido de tu mensaje.", - end_session=False, - ) + self._respond_alexa(_alexa_str(locale, "fallback"), end_session=False) return # Our custom intent: relay message to Claudio if intent_name == "SendMessageIntent": message = intent.get("slots", {}).get("message", {}).get("value", "") if not message: - self._respond_alexa("No escuché el mensaje. Intenta de nuevo.", end_session=False) + self._respond_alexa(_alexa_str(locale, "no_message"), end_session=False) return # Create synthetic Telegram webhook and enqueue it _enqueue_alexa_message(message) - self._respond_alexa("Ok, le paso el mensaje. ¿Algo más?", end_session=False, reprompt="¿Algo más para Claudio?") + self._respond_alexa( + _alexa_str(locale, "relayed"), + end_session=False, + reprompt=_alexa_str(locale, "reprompt"), + ) return # Unknown request type sys.stderr.write("[alexa] unhandled: req_type=%s intent=%s\n" % (req_type, intent_name)) - self._respond_alexa("No entendí la solicitud.", end_session=True) + self._respond_alexa(_alexa_str(locale, "unknown"), end_session=True) def _respond_alexa(self, text, end_session=True, reprompt=None): """Send an Alexa-formatted JSON response.""" @@ -340,6 +339,39 @@ def do_GET(self): _alexa_update_counter = 0 _alexa_counter_lock = threading.Lock() +# Alexa response strings by locale (2-letter language code) +_ALEXA_STRINGS = { + "es": { + "shutting_down": "Lo siento, estoy reiniciando. Intenta en un momento.", + "launch": "Dime qué le quieres decir a Claudio.", + "goodbye": "Adiós.", + "help": "Puedes decirme cualquier mensaje y se lo paso a Claudio por Telegram.", + "fallback": "No entendí. Intenta decir: dile a Claudio, seguido de tu mensaje.", + "no_message": "No escuché el mensaje. Intenta de nuevo.", + "relayed": "Ok, le paso el mensaje. ¿Algo más?", + "reprompt": "¿Algo más para Claudio?", + "unknown": "No entendí la solicitud.", + }, + "en": { + "shutting_down": "Sorry, I'm restarting. Try again in a moment.", + "launch": "Tell me what you want to say to Claudio.", + "goodbye": "Goodbye.", + "help": "Say any message and I'll relay it to Claudio on Telegram.", + "fallback": "I didn't catch that. Try saying: tell Claudio, followed by your message.", + "no_message": "I didn't hear the message. Try again.", + "relayed": "Ok, message sent. Anything else?", + "reprompt": "Anything else for Claudio?", + "unknown": "I didn't understand the request.", + }, +} + + +def _alexa_str(locale, key): + """Get a localized Alexa response string. Falls back to English.""" + lang = (locale or "en")[:2].lower() + strings = _ALEXA_STRINGS.get(lang, _ALEXA_STRINGS["en"]) + return strings[key] + def _enqueue_alexa_message(message): """Create a synthetic Telegram webhook body from an Alexa message and enqueue it.""" From 031bf3873d8efa52b1c6088ec2517249fc7bafdb Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:13:01 -0600 Subject: [PATCH 07/15] Consolidate Alexa docs into single section --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 4f48233..7264902 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,6 @@ The whole purpose of Claudio is to give you remote access to Claude Code from yo Since there's no human in front of the terminal to approve permission prompts, Claude Code must run autonomously. Rather than using `--dangerously-skip-permissions`, Claudio explicitly lists the tools Claude can use and auto-approves them — excluding interactive-only tools like `AskUserQuestion` and `Chrome`. Claudio mitigates the risk through: webhook secret validation (HMAC), single authorized `chat_id`, and binding the HTTP server to localhost only (external access goes through cloudflared). -> **⚠️ Alexa Integration (Optional) — Higher Security Risk** -> -> The optional Alexa skill integration exposes an additional `/alexa` endpoint that accepts voice commands and relays them to Claude Code via Telegram. This carries a *higher security risk* than the Telegram-only setup because: (1) anyone with physical access to your Alexa device can send commands to Claude Code — there is no per-user authentication beyond Amazon's skill ID validation, (2) if the `cryptography` Python library is not installed, signature verification falls back to timestamp-only checks, and (3) unlike Telegram (which binds to a single `chat_id`), the Alexa endpoint relies on the skill remaining private (unpublished) to limit access. **Do not enable Alexa integration unless you understand these risks.** See the [Alexa](#alexa) section for setup instructions. - ### Requirements - Claude Code CLI (with Pro/Max subscription) @@ -181,7 +177,7 @@ You can send documents (PDF, text files, CSV, code files, etc.) to Claudio. Incl ### Alexa -> **This integration is optional and carries additional security risks.** See the [security warning](#caution-security-risk) above before enabling. +> **⚠️ This integration is optional and carries additional security risks.** The Alexa skill exposes an additional `/alexa` endpoint that accepts voice commands and relays them to Claude Code via Telegram. This carries a *higher security risk* than the Telegram-only setup because: (1) anyone with physical access to your Alexa device can send commands to Claude Code — there is no per-user authentication beyond Amazon's skill ID validation, (2) if the `cryptography` Python library is not installed, signature verification falls back to timestamp-only checks, and (3) unlike Telegram (which binds to a single `chat_id`), the Alexa endpoint relies on the skill remaining private (unpublished) to limit access. **Do not enable Alexa integration unless you understand these risks.** Claudio can receive voice commands through an Amazon Alexa skill. When you speak to Alexa, the message is relayed to Claude Code via the same Telegram pipeline — Claude's response appears in your Telegram chat. From 3417e5d2c7b35a0ecb4663bc209aa1ad6623daac Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:18:32 -0600 Subject: [PATCH 08/15] =?UTF-8?q?docs:=20clarify=20Alexa=20requirements=20?= =?UTF-8?q?=E2=80=94=20cryptography=20and=20ALEXA=5FSKILL=5FID=20are=20req?= =?UTF-8?q?uired,=20not=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7264902..19fae24 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ You can send documents (PDF, text files, CSV, code files, etc.) to Claudio. Incl ### Alexa -> **⚠️ This integration is optional and carries additional security risks.** The Alexa skill exposes an additional `/alexa` endpoint that accepts voice commands and relays them to Claude Code via Telegram. This carries a *higher security risk* than the Telegram-only setup because: (1) anyone with physical access to your Alexa device can send commands to Claude Code — there is no per-user authentication beyond Amazon's skill ID validation, (2) if the `cryptography` Python library is not installed, signature verification falls back to timestamp-only checks, and (3) unlike Telegram (which binds to a single `chat_id`), the Alexa endpoint relies on the skill remaining private (unpublished) to limit access. **Do not enable Alexa integration unless you understand these risks.** +> **⚠️ This integration is optional and carries additional security risks.** The Alexa skill exposes an additional `/alexa` endpoint that accepts voice commands and relays them to Claude Code via Telegram. This carries a *higher security risk* than the Telegram-only setup because: (1) anyone with physical access to your Alexa device can send commands to Claude Code — there is no per-user authentication beyond Amazon's skill ID validation, and (2) unlike Telegram (which binds to a single `chat_id`), the Alexa endpoint relies on the skill remaining private (unpublished) to limit access. Both `cryptography` and `ALEXA_SKILL_ID` are required — the endpoint is disabled without them. **Do not enable Alexa integration unless you understand these risks.** Claudio can receive voice commands through an Amazon Alexa skill. When you speak to Alexa, the message is relayed to Claude Code via the same Telegram pipeline — Claude's response appears in your Telegram chat. @@ -191,7 +191,7 @@ Claudio can receive voice commands through an Amazon Alexa skill. When you speak **Setup:** -1. Install the `cryptography` Python library (strongly recommended): +1. Install the `cryptography` Python library (required for signature verification): ```bash pip3 install cryptography @@ -216,9 +216,8 @@ claudio restart **Security considerations:** -- Without `cryptography`, signature verification falls back to timestamp + header checks only (no certificate chain validation) +- `cryptography` and `ALEXA_SKILL_ID` are both required — without them, the Alexa endpoint is disabled - Anyone with physical access to your Alexa device can send commands — there is no voice PIN or per-user auth -- Setting `ALEXA_SKILL_ID` is strongly recommended to reject requests from other skills - Alexa messages appear in Telegram prefixed with `[Mensaje por voz desde Alexa]` so you can distinguish the source ### Parallel Work @@ -281,7 +280,7 @@ The following variables can be set in `$HOME/.claudio/service.env`: **Alexa (Optional)** -- `ALEXA_SKILL_ID` — Amazon Alexa skill application ID. If set, only requests from this skill are accepted. Strongly recommended for security. +- `ALEXA_SKILL_ID` — Amazon Alexa skill application ID. Required to enable the Alexa endpoint. **Voice (TTS/STT)** From 55d16da190ef970195e27b5a1fdfcb8cc0186af7 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:26:48 -0600 Subject: [PATCH 09/15] Use English in Alexa examples in README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 19fae24..d0b47a3 100644 --- a/README.md +++ b/README.md @@ -183,11 +183,11 @@ Claudio can receive voice commands through an Amazon Alexa skill. When you speak **How it works:** -1. You say: _"Alexa, abre Claudio"_ → Alexa opens the skill +1. You say: _"Alexa, open Claudio"_ → Alexa opens the skill 2. You say your message → Alexa sends it to the `/alexa` endpoint 3. Claudio relays it to Claude Code as a synthetic Telegram message 4. Claude's response appears in your Telegram chat -5. Alexa asks _"¿Algo más?"_ — you can send another message or say _"No"_ to end +5. Alexa asks _"Anything else?"_ — you can send another message or say _"No"_ to end **Setup:** @@ -218,7 +218,7 @@ claudio restart - `cryptography` and `ALEXA_SKILL_ID` are both required — without them, the Alexa endpoint is disabled - Anyone with physical access to your Alexa device can send commands — there is no voice PIN or per-user auth -- Alexa messages appear in Telegram prefixed with `[Mensaje por voz desde Alexa]` so you can distinguish the source +- Alexa messages appear in Telegram prefixed with `[Alexa voice query]` so you can distinguish the source ### Parallel Work From 52880d610fb27b0c497c887196046a3e1e831ae5 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:20:14 -0600 Subject: [PATCH 10/15] docs: add NoIntent and English utterances to Alexa setup --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d0b47a3..ddf0f2d 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,9 @@ pip3 install cryptography - Endpoint: HTTPS, URL: `https:///alexa` - SSL certificate type: _"My development endpoint is a sub-domain of a domain that has a wildcard certificate from a certificate authority"_ - Create a custom intent `SendMessageIntent` with a slot `message` of type `AMAZON.SearchQuery` - - Add sample utterances: `dile {message}`, `dile a claudio {message}`, `{message}` - - Enable built-in intents: `AMAZON.CancelIntent`, `AMAZON.StopIntent`, `AMAZON.HelpIntent`, `AMAZON.FallbackIntent` + - Add sample utterances (Spanish): `dile {message}`, `dile a claudio {message}`, `{message}` + - Add sample utterances (English): `tell him {message}`, `tell claudio {message}`, `{message}` + - Enable built-in intents: `AMAZON.CancelIntent`, `AMAZON.StopIntent`, `AMAZON.HelpIntent`, `AMAZON.FallbackIntent`, `AMAZON.NoIntent` 3. Copy the skill ID and add it to your config: From 818e155353550324015da1ad322dabe92daa8771 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:37:06 -0600 Subject: [PATCH 11/15] Buffer Alexa messages locally and flush as single transcript on session end Instead of sending each Alexa utterance as a separate webhook, buffer messages per session and flush them as a single transcript when the user says goodbye or the session times out. Includes expanded sample utterances with carrier phrases to reduce Alexa catch-all escapes. --- README.md | 25 +++++++++++- lib/server.py | 109 ++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 115 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ddf0f2d..694b48f 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,29 @@ pip3 install cryptography - Endpoint: HTTPS, URL: `https:///alexa` - SSL certificate type: _"My development endpoint is a sub-domain of a domain that has a wildcard certificate from a certificate authority"_ - Create a custom intent `SendMessageIntent` with a slot `message` of type `AMAZON.SearchQuery` - - Add sample utterances (Spanish): `dile {message}`, `dile a claudio {message}`, `{message}` - - Add sample utterances (English): `tell him {message}`, `tell claudio {message}`, `{message}` + - Add sample utterances (Spanish): + ``` + {message} + dile {message} + dile a claudio {message} + que {message} + y {message} + también {message} + y también {message} + pregúntale {message} + dile que {message} + ``` + - Add sample utterances (English): + ``` + {message} + tell him {message} + tell claudio {message} + and {message} + also {message} + and also {message} + ask him {message} + tell him that {message} + ``` - Enable built-in intents: `AMAZON.CancelIntent`, `AMAZON.StopIntent`, `AMAZON.HelpIntent`, `AMAZON.FallbackIntent`, `AMAZON.NoIntent` 3. Copy the skill ID and add it to your config: diff --git a/lib/server.py b/lib/server.py index 0222be9..474525f 100644 --- a/lib/server.py +++ b/lib/server.py @@ -257,10 +257,18 @@ def _handle_alexa(self): data.get("session", {}).get("new"))) if req_type == "LaunchRequest": - self._respond_alexa(_alexa_str(locale, "launch"), end_session=False) + self._respond_alexa( + _alexa_str(locale, "launch"), + end_session=False, + reprompt=_alexa_str(locale, "reprompt"), + ) return + session_id = data.get("session", {}).get("sessionId", "") + if req_type == "SessionEndedRequest": + # Flush buffered messages before closing + _flush_alexa_session(session_id, locale) self._respond_alexa("", end_session=True) return @@ -270,7 +278,10 @@ def _handle_alexa(self): # Built-in intents if intent_name in ("AMAZON.CancelIntent", "AMAZON.StopIntent", "AMAZON.NoIntent"): - self._respond_alexa(_alexa_str(locale, "goodbye"), end_session=True) + has_messages = _alexa_session_has_messages(session_id) + _flush_alexa_session(session_id, locale) + goodbye_key = "goodbye" if has_messages else "goodbye_empty" + self._respond_alexa(_alexa_str(locale, goodbye_key), end_session=True) return if intent_name == "AMAZON.HelpIntent": self._respond_alexa(_alexa_str(locale, "help"), end_session=False) @@ -279,17 +290,16 @@ def _handle_alexa(self): self._respond_alexa(_alexa_str(locale, "fallback"), end_session=False) return - # Our custom intent: relay message to Claudio + # Our custom intent: buffer message locally if intent_name == "SendMessageIntent": message = intent.get("slots", {}).get("message", {}).get("value", "") if not message: self._respond_alexa(_alexa_str(locale, "no_message"), end_session=False) return - # Create synthetic Telegram webhook and enqueue it - _enqueue_alexa_message(message) + _buffer_alexa_message(session_id, message, locale) self._respond_alexa( - _alexa_str(locale, "relayed"), + _alexa_str(locale, "buffered"), end_session=False, reprompt=_alexa_str(locale, "reprompt"), ) @@ -339,27 +349,34 @@ def do_GET(self): _alexa_update_counter = 0 _alexa_counter_lock = threading.Lock() +# Alexa session buffers: session_id -> {"messages": [...], "locale": str, "last_activity": float} +_alexa_sessions = {} +_alexa_sessions_lock = threading.Lock() +_ALEXA_SESSION_TTL = 300 # 5 min — cleanup stale sessions + # Alexa response strings by locale (2-letter language code) _ALEXA_STRINGS = { "es": { "shutting_down": "Lo siento, estoy reiniciando. Intenta en un momento.", "launch": "Dime qué le quieres decir a Claudio.", - "goodbye": "Adiós.", - "help": "Puedes decirme cualquier mensaje y se lo paso a Claudio por Telegram.", + "goodbye": "Listo, le paso todo a Claudio. Adiós.", + "goodbye_empty": "Adiós.", + "help": "Puedes decirme varios mensajes y al final se los paso todos juntos a Claudio por Telegram. Di 'eso es todo' cuando termines.", "fallback": "No entendí. Intenta decir: dile a Claudio, seguido de tu mensaje.", "no_message": "No escuché el mensaje. Intenta de nuevo.", - "relayed": "Ok, le paso el mensaje. ¿Algo más?", + "buffered": "Anotado. ¿Algo más?", "reprompt": "¿Algo más para Claudio?", "unknown": "No entendí la solicitud.", }, "en": { "shutting_down": "Sorry, I'm restarting. Try again in a moment.", "launch": "Tell me what you want to say to Claudio.", - "goodbye": "Goodbye.", - "help": "Say any message and I'll relay it to Claudio on Telegram.", + "goodbye": "Got it, sending everything to Claudio. Goodbye.", + "goodbye_empty": "Goodbye.", + "help": "You can send multiple messages and I'll relay them all to Claudio at the end. Say 'that's all' when you're done.", "fallback": "I didn't catch that. Try saying: tell Claudio, followed by your message.", "no_message": "I didn't hear the message. Try again.", - "relayed": "Ok, message sent. Anything else?", + "buffered": "Noted. Anything else?", "reprompt": "Anything else for Claudio?", "unknown": "I didn't understand the request.", }, @@ -373,11 +390,54 @@ def _alexa_str(locale, key): return strings[key] -def _enqueue_alexa_message(message): - """Create a synthetic Telegram webhook body from an Alexa message and enqueue it.""" +def _buffer_alexa_message(session_id, message, locale): + """Add a message to the Alexa session buffer.""" + with _alexa_sessions_lock: + if session_id not in _alexa_sessions: + _alexa_sessions[session_id] = { + "messages": [], + "locale": locale, + "last_activity": time.monotonic(), + } + _alexa_sessions[session_id]["messages"].append(message) + _alexa_sessions[session_id]["last_activity"] = time.monotonic() + count = len(_alexa_sessions[session_id]["messages"]) + sys.stderr.write(f"[alexa] Buffered message #{count} for session {session_id[:16]}...\n") + + # Cleanup stale sessions while we're here + _cleanup_stale_alexa_sessions() + + +def _alexa_session_has_messages(session_id): + """Check if a session has buffered messages.""" + with _alexa_sessions_lock: + session = _alexa_sessions.get(session_id) + return bool(session and session["messages"]) + + +def _flush_alexa_session(session_id, locale): + """Flush all buffered messages for a session as a single webhook.""" + with _alexa_sessions_lock: + session = _alexa_sessions.pop(session_id, None) + + if not session or not session["messages"]: + sys.stderr.write(f"[alexa] No messages to flush for session {session_id[:16]}...\n") + return + + messages = session["messages"] + sys.stderr.write(f"[alexa] Flushing {len(messages)} message(s) for session {session_id[:16]}...\n") + + # Build transcript + lines = ["_[Alexa session transcript]:_", ""] + for msg in messages: + lines.append(f'- "{msg}"') + + transcript = "\n".join(lines) + if not TELEGRAM_CHAT_ID: - sys.stderr.write("[alexa] TELEGRAM_CHAT_ID not configured, cannot relay message\n") + sys.stderr.write("[alexa] TELEGRAM_CHAT_ID not configured, cannot relay\n") return + global _alexa_update_counter with _alexa_counter_lock: _alexa_update_counter += 1 @@ -390,12 +450,25 @@ def _enqueue_alexa_message(message): "date": int(time.time()), "chat": {"id": int(TELEGRAM_CHAT_ID), "type": "private"}, "from": {"id": int(TELEGRAM_CHAT_ID), "first_name": "Alexa", "is_bot": False}, - "text": f'_[Alexa voice query]:_ "{message}"', + "text": transcript, }, }) enqueue_webhook(body) +def _cleanup_stale_alexa_sessions(): + """Remove sessions older than TTL to prevent memory leaks.""" + now = time.monotonic() + with _alexa_sessions_lock: + stale = [sid for sid, s in _alexa_sessions.items() + if now - s["last_activity"] > _ALEXA_SESSION_TTL] + for sid in stale: + session = _alexa_sessions.pop(sid) + count = len(session["messages"]) + if count: + sys.stderr.write(f"[alexa] Stale session {sid[:16]}... expired with {count} unflushed message(s)\n") + + def _verify_alexa_request(headers, body): """Verify that the request comes from Alexa by validating the certificate chain. @@ -484,7 +557,9 @@ def _verify_alexa_signature(cert_url, signature_b64, body): # Validate certificate is currently valid now_utc = datetime.now(timezone.utc) - if now_utc < cert.not_valid_before_utc or now_utc > cert.not_valid_after_utc: + not_before = getattr(cert, "not_valid_before_utc", cert.not_valid_before.replace(tzinfo=timezone.utc)) + not_after = getattr(cert, "not_valid_after_utc", cert.not_valid_after.replace(tzinfo=timezone.utc)) + if now_utc < not_before or now_utc > not_after: sys.stderr.write("[alexa] Certificate is expired or not yet valid\n") return False From 63cfa806888ad28bdcf87794e12fdf0f2f356d1d Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:53:07 -0600 Subject: [PATCH 12/15] Remove standalone {message} utterance (requires carrier phrase) --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 694b48f..6877ae0 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,6 @@ pip3 install cryptography - Create a custom intent `SendMessageIntent` with a slot `message` of type `AMAZON.SearchQuery` - Add sample utterances (Spanish): ``` - {message} dile {message} dile a claudio {message} que {message} @@ -216,7 +215,6 @@ pip3 install cryptography ``` - Add sample utterances (English): ``` - {message} tell him {message} tell claudio {message} and {message} From e8b48fabe537a3e7d1557bf06af358e1cfe6cdd0 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:13:20 -0600 Subject: [PATCH 13/15] Expand Alexa sample utterances for better catch-all coverage Add carrier phrase variants in both Spanish and English to reduce Alexa escaping to native intents. Covers action verbs, interrogatives, continuations, and polite forms. --- README.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6877ae0..7b90874 100644 --- a/README.md +++ b/README.md @@ -205,23 +205,46 @@ pip3 install cryptography - Add sample utterances (Spanish): ``` dile {message} + dile que {message} dile a claudio {message} + dile a claudio que {message} que {message} y {message} también {message} y también {message} pregúntale {message} - dile que {message} + pregúntale que {message} + pregúntale a claudio {message} + luego {message} + luego que {message} + pero {message} + además {message} + aparte {message} + manda {message} + pásale {message} + por favor dile {message} + dile por favor {message} ``` - Add sample utterances (English): ``` tell him {message} + tell him that {message} tell claudio {message} + tell claudio that {message} and {message} also {message} and also {message} ask him {message} - tell him that {message} + ask him about {message} + ask claudio {message} + ask claudio about {message} + then {message} + but {message} + also ask {message} + send {message} + pass along {message} + please tell him {message} + tell him please {message} ``` - Enable built-in intents: `AMAZON.CancelIntent`, `AMAZON.StopIntent`, `AMAZON.HelpIntent`, `AMAZON.FallbackIntent`, `AMAZON.NoIntent` From 50cd3eec886f5b49a27a4292cc5f7396539c9278 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:13:58 -0600 Subject: [PATCH 14/15] Add links to official Alexa utterance documentation in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7b90874..bc87bf4 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ pip3 install cryptography please tell him {message} tell him please {message} ``` + - **Note:** `AMAZON.SearchQuery` slots require a carrier phrase — the slot cannot be the only word in the utterance, and it must appear at the end. For best practices on designing and testing utterances, see the official Alexa documentation: https://developer.amazon.com/en-US/docs/alexa/custom-skills/best-practices-for-sample-utterances-and-custom-slot-type-values.html and https://developer.amazon.com/en-US/docs/alexa/custom-skills/test-utterances-and-improve-your-interaction-model.html - Enable built-in intents: `AMAZON.CancelIntent`, `AMAZON.StopIntent`, `AMAZON.HelpIntent`, `AMAZON.FallbackIntent`, `AMAZON.NoIntent` 3. Copy the skill ID and add it to your config: From 4dc7ef0cb6331c15a2d6b21adc88b5c1790563c5 Mon Sep 17 00:00:00 2001 From: Claudio <257189482+claudio-pi@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:31:45 -0600 Subject: [PATCH 15/15] Remove Alexa label from relayed messages to avoid filtering bias --- lib/server.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/server.py b/lib/server.py index 474525f..547c53c 100644 --- a/lib/server.py +++ b/lib/server.py @@ -427,12 +427,14 @@ def _flush_alexa_session(session_id, locale): messages = session["messages"] sys.stderr.write(f"[alexa] Flushing {len(messages)} message(s) for session {session_id[:16]}...\n") - # Build transcript - lines = ["_[Alexa session transcript]:_", ""] - for msg in messages: - lines.append(f'- "{msg}"') - - transcript = "\n".join(lines) + # Build transcript — no "Alexa" label to avoid Claude filtering out requests + if len(messages) == 1: + transcript = messages[0] + else: + lines = [] + for msg in messages: + lines.append(f'- "{msg}"') + transcript = "\n".join(lines) if not TELEGRAM_CHAT_ID: sys.stderr.write("[alexa] TELEGRAM_CHAT_ID not configured, cannot relay\n")