diff --git a/README.md b/README.md index 986f4a1..5c71ff9 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,19 @@ inbound calls only. libphonenumber) to determine country and timezone 5. For US/CA numbers, uses the 3-digit area code to narrow to a specific timezone -6. Creates an internal note on the contact with timezone details (visible in - all Inbox views) -7. Sets an `inferred_timezone` custom attribute on the contact (filterable, - usable in reports) +6. Creates an internal note on the contact with timezone details and a stable + receipt derived from the Intercom call ID (visible in all Inbox views) +7. On Intercom's one-minute webhook retry, reads the conversation and skips the + note write when that call receipt already exists +8. Sets an `inferred_timezone` custom attribute on the contact (filterable, + usable in reports). The note and attribute writes run concurrently so the + normal webhook path can acknowledge within Intercom's five-second deadline. + +If a note request has an ambiguous outcome, the handler reads the conversation +and treats an existing exact call receipt as success. If the receipt cannot be +proved, or the contact attribute update fails, it returns an error so Intercom's +single retry can safely finish the work. A missing/invalid call ID suppresses +the note rather than performing a write that cannot be deduplicated. ## Project Structure @@ -283,7 +292,7 @@ repository-root `.env` used by `doctl serverless deploy . --remote-build --env | `WEBHOOK_SECRET` | Intercom app client secret | | `LEAD_TO_USER_CONVERSION_ENABLED` | Optional. Set to `true` to enable server-side lead→user merge. **Off by default** because the merge breaks live Messenger sessions (see Lead-to-User section). | | `INTERCOM_DRAFT_BRIDGE_SECRET` | Random bearer secret required by the draft bridge. | -| `INTERCOM_ARTICLE_AUTHOR_ID` | Intercom teammate/admin ID used as the article author. | +| `INTERCOM_ARTICLE_AUTHOR_ID` | Pinned full-seat Intercom teammate/admin ID used as the article author and, through a function-local alias, the call-location note author. | ### Intercom Webhook Setup diff --git a/packages/intercom/tests/serve_local.py b/packages/intercom/tests/serve_local.py index a2ac34e..ef84f0d 100644 --- a/packages/intercom/tests/serve_local.py +++ b/packages/intercom/tests/serve_local.py @@ -102,6 +102,11 @@ def run(port=8080): missing.append("INTERCOM_ACCESS_TOKEN") if not os.environ.get("WEBHOOK_SECRET"): missing.append("WEBHOOK_SECRET") + if not ( + os.environ.get("INTERCOM_WEBHOOK_ADMIN_ID") + or os.environ.get("INTERCOM_ARTICLE_AUTHOR_ID") + ): + missing.append("INTERCOM_ARTICLE_AUTHOR_ID") if missing: print(f"ERROR: Missing environment variables: {', '.join(missing)}") print("Copy .env.example to .env and fill in your real values.") diff --git a/packages/intercom/tests/test_call_timezone.py b/packages/intercom/tests/test_call_timezone.py index 984e9a9..039549d 100644 --- a/packages/intercom/tests/test_call_timezone.py +++ b/packages/intercom/tests/test_call_timezone.py @@ -3,8 +3,10 @@ import hmac import importlib.util import json -import sys import os +from pathlib import Path +import sys +from threading import Barrier from unittest.mock import patch, MagicMock import pytest @@ -22,17 +24,29 @@ WEBHOOK_SECRET = "test-secret-key" INTERCOM_TOKEN = "fake-token" - - -def _make_call_payload(phone="+12125551234", contact_id="contact_abc", direction="inbound", - conversation_id="conv_123", topic="call.started"): +INTERCOM_ADMIN_ID = "12345" + + +def _make_call_payload( + phone="+12125551234", + contact_id="contact_abc", + direction="inbound", + conversation_id="conv_123", + topic="call.started", + call_id="call_123", + notification_id="notif_123", + delivery_attempts=1, +): return { "type": "notification_event", + "id": notification_id, "topic": topic, + "delivery_attempts": delivery_attempts, "data": { "type": "notification_event_data", "item": { "type": "call", + "id": call_id, "phone": phone, "contact_id": contact_id, "conversation_id": conversation_id, @@ -88,16 +102,32 @@ def _make_event_base64(payload_dict, secret=WEBHOOK_SECRET): def _mock_ok_response(data=None): resp = MagicMock() resp.status_code = 200 + resp.ok = True resp.json.return_value = data or {} resp.raise_for_status = MagicMock() return resp +def _conversation_with_bodies(*bodies): + return { + "type": "conversation", + "id": "conv_123", + "conversation_parts": { + "type": "conversation_part.list", + "conversation_parts": [ + {"type": "conversation_part", "part_type": "note", "body": body} + for body in bodies + ], + "total_count": len(bodies), + }, + } + + @pytest.fixture(autouse=True) def _set_env(monkeypatch): monkeypatch.setenv("WEBHOOK_SECRET", WEBHOOK_SECRET) monkeypatch.setenv("INTERCOM_ACCESS_TOKEN", INTERCOM_TOKEN) - tz_intercom_client._cached_admin_id = None + monkeypatch.setenv("INTERCOM_WEBHOOK_ADMIN_ID", INTERCOM_ADMIN_ID) # ── Timezone inference (pure logic, no mocks needed) ───────────────── @@ -160,8 +190,7 @@ def test_invalid_number_returns_none(self): class TestSignatureVerification: def test_valid_signature_accepted(self): event = _make_event(_make_call_payload()) - with patch("intercom_client.requests.get", return_value=_mock_ok_response({"id": "admin_1"})), \ - patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ + with patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ patch("intercom_client.requests.put", return_value=_mock_ok_response()): result = handler.main(event, None) assert result["statusCode"] == 200 @@ -179,8 +208,7 @@ def test_missing_signature_rejected(self): def test_base64_encoded_body(self): event = _make_event_base64(_make_call_payload()) - with patch("intercom_client.requests.get", return_value=_mock_ok_response({"id": "admin_1"})), \ - patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ + with patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ patch("intercom_client.requests.put", return_value=_mock_ok_response()): result = handler.main(event, None) assert result["statusCode"] == 200 @@ -247,8 +275,7 @@ def test_malformed_body_returns_400(self): class TestHandlerFlow: def test_inbound_us_call_creates_note_and_updates_attribute(self): event = _make_event(_make_call_payload(phone="+12125551234", contact_id="ctc_1")) - with patch("intercom_client.requests.get", return_value=_mock_ok_response({"id": "admin_1"})), \ - patch("intercom_client.requests.post", return_value=_mock_ok_response()) as mock_post, \ + with patch("intercom_client.requests.post", return_value=_mock_ok_response()) as mock_post, \ patch("intercom_client.requests.put", return_value=_mock_ok_response()) as mock_put: result = handler.main(event, None) @@ -262,6 +289,9 @@ def test_inbound_us_call_creates_note_and_updates_attribute(self): note_body = note_call[1]["json"]["body"] assert "America/" in note_body assert "212" in note_body + assert "[edge-call-location call_id=call_123]" in note_body + assert note_call[1]["json"]["admin_id"] == INTERCOM_ADMIN_ID + assert note_call[1]["timeout"] == (1, 2) assert mock_put.call_count == 1 attr_call = mock_put.call_args @@ -272,8 +302,7 @@ def test_inbound_us_call_creates_note_and_updates_attribute(self): def test_inbound_uk_call(self): event = _make_event(_make_call_payload(phone="+442071234567", contact_id="ctc_2")) - with patch("intercom_client.requests.get", return_value=_mock_ok_response({"id": "admin_1"})), \ - patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ + with patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ patch("intercom_client.requests.put", return_value=_mock_ok_response()) as mock_put: result = handler.main(event, None) @@ -281,6 +310,21 @@ def test_inbound_uk_call(self): attr_call = mock_put.call_args assert attr_call[1]["json"]["custom_attributes"]["inferred_timezone"] == "Europe/London" + def test_first_delivery_runs_note_and_attribute_writes_in_parallel(self): + rendezvous = Barrier(2, timeout=1) + + def wait_for_other_write(*args, **kwargs): + rendezvous.wait() + return _mock_ok_response() + + event = _make_event(_make_call_payload()) + with patch( + "intercom_client.requests.post", side_effect=wait_for_other_write + ), patch("intercom_client.requests.put", side_effect=wait_for_other_write): + result = handler.main(event, None) + + assert result == {"statusCode": 200, "body": "OK"} + def test_no_conversation_id_skips_note(self): payload = _make_call_payload() del payload["data"]["item"]["conversation_id"] @@ -293,21 +337,205 @@ def test_no_conversation_id_skips_note(self): assert result["statusCode"] == 200 mock_post.assert_not_called() - def test_note_failure_does_not_block_attribute_update(self): + def test_note_failure_returns_retryable_error_after_absent_receipt(self): event = _make_event(_make_call_payload()) - with patch("intercom_client.requests.get", return_value=_mock_ok_response({"id": "admin_1"})), \ - patch("intercom_client.requests.post", side_effect=Exception("API error")), \ + with patch( + "intercom_client.requests.get", + return_value=_mock_ok_response(_conversation_with_bodies()), + ), patch("intercom_client.requests.post", side_effect=Exception("API error")), \ patch("intercom_client.requests.put", return_value=_mock_ok_response()) as mock_put: result = handler.main(event, None) - assert result["statusCode"] == 200 + assert result["statusCode"] == 500 assert mock_put.call_count == 1 - def test_attribute_failure_still_returns_200(self): + def test_attribute_failure_returns_retryable_error(self): event = _make_event(_make_call_payload()) - with patch("intercom_client.requests.get", return_value=_mock_ok_response({"id": "admin_1"})), \ - patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ + with patch("intercom_client.requests.post", return_value=_mock_ok_response()), \ patch("intercom_client.requests.put", side_effect=Exception("API error")): result = handler.main(event, None) - assert result["statusCode"] == 200 + assert result["statusCode"] == 500 + + def test_one_minute_retry_with_receipt_does_not_create_second_note(self): + note_bodies = [] + + def create_note(*args, **kwargs): + note_bodies.append(kwargs["json"]["body"]) + return _mock_ok_response() + + def get_conversation(*args, **kwargs): + return _mock_ok_response(_conversation_with_bodies(*note_bodies)) + + first = _make_event(_make_call_payload(delivery_attempts=1)) + retry = _make_event(_make_call_payload(delivery_attempts=2)) + + with patch("intercom_client.requests.get", side_effect=get_conversation) as mock_get, \ + patch("intercom_client.requests.post", side_effect=create_note) as mock_post, \ + patch("intercom_client.requests.put", return_value=_mock_ok_response()) as mock_put: + first_result = handler.main(first, None) + retry_result = handler.main(retry, None) + + assert first_result == {"statusCode": 200, "body": "OK"} + assert retry_result == {"statusCode": 200, "body": "OK — replay covered"} + assert mock_get.call_count == 1 + assert mock_post.call_count == 1 + assert mock_put.call_count == 2 + assert len(note_bodies) == 1 + + def test_retry_without_receipt_creates_note(self): + event = _make_event(_make_call_payload(delivery_attempts=2)) + with patch( + "intercom_client.requests.get", + return_value=_mock_ok_response(_conversation_with_bodies()), + ) as mock_get, patch( + "intercom_client.requests.post", return_value=_mock_ok_response() + ) as mock_post, patch( + "intercom_client.requests.put", return_value=_mock_ok_response() + ): + result = handler.main(event, None) + + assert result == {"statusCode": 200, "body": "OK"} + assert mock_get.call_count == 1 + assert mock_post.call_count == 1 + + def test_ambiguous_note_write_reconciles_existing_receipt(self): + marker = "[edge-call-location call_id=call_123]" + event = _make_event(_make_call_payload()) + with patch( + "intercom_client.requests.get", + return_value=_mock_ok_response(_conversation_with_bodies(marker)), + ) as mock_get, patch( + "intercom_client.requests.post", + side_effect=TimeoutError("response lost after commit"), + ) as mock_post, patch( + "intercom_client.requests.put", return_value=_mock_ok_response() + ) as mock_put: + result = handler.main(event, None) + + assert result == {"statusCode": 200, "body": "OK"} + assert mock_get.call_count == 1 + assert mock_post.call_count == 1 + assert mock_put.call_count == 1 + + def test_attribute_failure_retry_uses_existing_note_receipt(self): + note_bodies = [] + + def create_note(*args, **kwargs): + note_bodies.append(kwargs["json"]["body"]) + return _mock_ok_response() + + def get_conversation(*args, **kwargs): + return _mock_ok_response(_conversation_with_bodies(*note_bodies)) + + first = _make_event(_make_call_payload(delivery_attempts=1)) + retry = _make_event(_make_call_payload(delivery_attempts=2)) + with patch("intercom_client.requests.get", side_effect=get_conversation), \ + patch("intercom_client.requests.post", side_effect=create_note) as mock_post, \ + patch( + "intercom_client.requests.put", + side_effect=[Exception("attribute timeout"), _mock_ok_response()], + ): + first_result = handler.main(first, None) + retry_result = handler.main(retry, None) + + assert first_result["statusCode"] == 500 + assert retry_result == {"statusCode": 200, "body": "OK — replay covered"} + assert mock_post.call_count == 1 + assert len(note_bodies) == 1 + + def test_different_call_id_is_not_covered_by_old_receipt(self): + old_marker = "[edge-call-location call_id=call_old]" + event = _make_event( + _make_call_payload(call_id="call_new", delivery_attempts=2) + ) + with patch( + "intercom_client.requests.get", + return_value=_mock_ok_response(_conversation_with_bodies(old_marker)), + ), patch( + "intercom_client.requests.post", return_value=_mock_ok_response() + ) as mock_post, patch( + "intercom_client.requests.put", return_value=_mock_ok_response() + ): + result = handler.main(event, None) + + assert result == {"statusCode": 200, "body": "OK"} + assert mock_post.call_count == 1 + assert "call_new" in mock_post.call_args.kwargs["json"]["body"] + + @pytest.mark.parametrize("call_id", [None, "", True, "bad id", ""]) + def test_missing_or_invalid_call_id_skips_note(self, call_id): + event = _make_event(_make_call_payload(call_id=call_id)) + with patch("intercom_client.requests.get") as mock_get, \ + patch("intercom_client.requests.post") as mock_post, \ + patch( + "intercom_client.requests.put", return_value=_mock_ok_response() + ) as mock_put: + result = handler.main(event, None) + + assert result == {"statusCode": 200, "body": "OK — note skipped"} + mock_get.assert_not_called() + mock_post.assert_not_called() + assert mock_put.call_count == 1 + + def test_missing_delivery_attempts_takes_receipt_check_path(self): + payload = _make_call_payload() + del payload["delivery_attempts"] + event = _make_event(payload) + with patch( + "intercom_client.requests.get", + return_value=_mock_ok_response(_conversation_with_bodies()), + ) as mock_get, patch( + "intercom_client.requests.post", return_value=_mock_ok_response() + ) as mock_post, patch( + "intercom_client.requests.put", return_value=_mock_ok_response() + ): + result = handler.main(event, None) + + assert result == {"statusCode": 200, "body": "OK"} + assert mock_get.call_count == 1 + assert mock_post.call_count == 1 + + def test_retry_fails_closed_when_receipt_readback_is_malformed(self): + event = _make_event(_make_call_payload(delivery_attempts=2)) + with patch( + "intercom_client.requests.get", return_value=_mock_ok_response({}) + ), patch("intercom_client.requests.post") as mock_post, patch( + "intercom_client.requests.put" + ) as mock_put: + result = handler.main(event, None) + + assert result == {"statusCode": 500, "body": "Receipt verification failed"} + mock_post.assert_not_called() + mock_put.assert_not_called() + + +def test_manifest_pins_webhook_note_admin_to_existing_author_id(): + project_root = Path(__file__).resolve().parents[3] + manifest = (project_root / "project.yml").read_text() + assert 'INTERCOM_WEBHOOK_ADMIN_ID: "${INTERCOM_ARTICLE_AUTHOR_ID}"' in manifest + + +def test_local_admin_id_falls_back_to_existing_article_author(monkeypatch): + monkeypatch.delenv("INTERCOM_WEBHOOK_ADMIN_ID", raising=False) + monkeypatch.setenv("INTERCOM_ARTICLE_AUTHOR_ID", "67890") + assert tz_intercom_client.get_webhook_admin_id() == "67890" + + +@pytest.mark.parametrize("admin_id", ["", "0", "0123", "-1", "admin_1", "١٢٣"]) +def test_invalid_note_admin_configuration_fails_before_intercom_write( + monkeypatch, admin_id +): + monkeypatch.setenv("INTERCOM_WEBHOOK_ADMIN_ID", admin_id) + monkeypatch.delenv("INTERCOM_ARTICLE_AUTHOR_ID", raising=False) + event = _make_event(_make_call_payload()) + + with patch("intercom_client.requests.get") as mock_get, patch( + "intercom_client.requests.post" + ) as mock_post, patch("intercom_client.requests.put") as mock_put: + result = handler.main(event, None) + + assert result == {"statusCode": 500, "body": "Webhook configuration error"} + mock_get.assert_not_called() + mock_post.assert_not_called() + mock_put.assert_not_called() diff --git a/packages/intercom/webhook/call_timezone/handler.py b/packages/intercom/webhook/call_timezone/handler.py index 0d10981..d001e52 100644 --- a/packages/intercom/webhook/call_timezone/handler.py +++ b/packages/intercom/webhook/call_timezone/handler.py @@ -7,15 +7,46 @@ Called by the webhook router for the call.started topic. """ +from concurrent.futures import ThreadPoolExecutor import logging +import re from call_timezone.timezone import infer_timezone -from intercom_client import create_conversation_note, update_contact_attributes +from intercom_client import ( + conversation_contains_note_marker, + create_conversation_note, + get_conversation, + get_webhook_admin_id, + update_contact_attributes, +) logger = logging.getLogger(__name__) +CALL_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$") -def _build_note_body(info: dict, phone: str) -> str: + +def _normalize_call_id(value) -> str | None: + """Normalize a trusted Intercom call ID without allowing note markup.""" + if isinstance(value, bool) or not isinstance(value, (str, int)): + return None + call_id = str(value).strip() + if not CALL_ID_PATTERN.fullmatch(call_id): + return None + return call_id + + +def _receipt_marker(call_id: str) -> str: + """Stable, durable marker carried by the internal note itself.""" + return f"[edge-call-location call_id={call_id}]" + + +def _is_first_delivery(payload: dict) -> bool: + """Only an explicit first attempt may take the low-latency fast path.""" + attempts = payload.get("delivery_attempts") + return not isinstance(attempts, bool) and attempts == 1 + + +def _build_note_body(info: dict, phone: str, call_id: str) -> str: """Format the timezone inference as a rich internal note.""" lines = [ f"🕐 {info['timezone']} ({info['utc_offset'] or '?'})", @@ -36,10 +67,38 @@ def _build_note_body(info: dict, phone: str) -> str: confidence_label = "High" if info["confidence"] == "high" else "Approximate" lines.append(f"Confidence: {confidence_label}") + lines.append(_receipt_marker(call_id)) return "
".join(lines) +def _receipt_exists(conversation_id: str, marker: str) -> bool: + conversation = get_conversation(conversation_id) + return conversation_contains_note_marker(conversation, marker) + + +def _write_note_and_timezone( + conversation_id: str, + note_body: str, + contact_id: str, + timezone: str, +) -> tuple[Exception | None, Exception | None]: + """Run the two independent Intercom writes within the webhook deadline.""" + with ThreadPoolExecutor(max_workers=2) as executor: + note_future = executor.submit( + create_conversation_note, conversation_id, note_body + ) + attribute_future = executor.submit( + update_contact_attributes, + contact_id, + {"inferred_timezone": timezone}, + ) + + note_error = note_future.exception() + attribute_error = attribute_future.exception() + return note_error, attribute_error + + def handle(payload): """Process a call.started webhook payload. Expects a pre-verified, parsed dict.""" item = payload.get("data", {}).get("item", {}) @@ -51,6 +110,7 @@ def handle(payload): phone = item.get("phone") contact_id = item.get("contact_id") conversation_id = item.get("conversation_id") + call_id = _normalize_call_id(item.get("id")) if not phone or not contact_id: logger.warning("Missing phone or contact_id in payload") @@ -65,18 +125,86 @@ def handle(payload): "Inferred %s for %s (confidence=%s)", info["timezone"], phone, info["confidence"] ) - if conversation_id: + if not conversation_id: + logger.warning("No conversation_id in payload, skipping note") try: - note_body = _build_note_body(info, phone) - create_conversation_note(conversation_id, note_body) + update_contact_attributes( + contact_id, {"inferred_timezone": info["timezone"]} + ) except Exception: - logger.exception("Failed to create note on conversation %s", conversation_id) - else: - logger.warning("No conversation_id in payload, skipping note") + logger.exception("Failed to update attributes for contact %s", contact_id) + return {"statusCode": 500, "body": "Intercom update failed"} + return {"statusCode": 200, "body": "OK — note skipped"} + + if not call_id: + logger.error("Missing or invalid stable call id; skipping non-idempotent note") + try: + update_contact_attributes( + contact_id, {"inferred_timezone": info["timezone"]} + ) + except Exception: + logger.exception("Failed to update attributes for contact %s", contact_id) + return {"statusCode": 500, "body": "Intercom update failed"} + return {"statusCode": 200, "body": "OK — note skipped"} + + marker = _receipt_marker(call_id) + + if not _is_first_delivery(payload): + try: + if _receipt_exists(conversation_id, marker): + logger.info("Call note receipt already exists for call %s", call_id) + try: + update_contact_attributes( + contact_id, {"inferred_timezone": info["timezone"]} + ) + except Exception: + logger.exception( + "Failed to update attributes for contact %s", contact_id + ) + return {"statusCode": 500, "body": "Intercom update failed"} + return {"statusCode": 200, "body": "OK — replay covered"} + except Exception: + logger.exception( + "Could not verify call note receipt for conversation %s", + conversation_id, + ) + return {"statusCode": 500, "body": "Receipt verification failed"} + note_body = _build_note_body(info, phone, call_id) try: - update_contact_attributes(contact_id, {"inferred_timezone": info["timezone"]}) + get_webhook_admin_id() except Exception: - logger.exception("Failed to update attributes for contact %s", contact_id) + logger.exception("Call note author configuration is invalid") + return {"statusCode": 500, "body": "Webhook configuration error"} + + note_error, attribute_error = _write_note_and_timezone( + conversation_id, + note_body, + contact_id, + info["timezone"], + ) + + if note_error is not None: + logger.error( + "Call note write failed for conversation %s; reconciling: %s", + conversation_id, + note_error, + ) + try: + if not _receipt_exists(conversation_id, marker): + return {"statusCode": 500, "body": "Intercom note failed"} + except Exception: + logger.exception( + "Could not reconcile call note for conversation %s", conversation_id + ) + return {"statusCode": 500, "body": "Note reconciliation failed"} + + if attribute_error is not None: + logger.error( + "Failed to update attributes for contact %s: %s", + contact_id, + attribute_error, + ) + return {"statusCode": 500, "body": "Intercom update failed"} return {"statusCode": 200, "body": "OK"} diff --git a/packages/intercom/webhook/intercom_client.py b/packages/intercom/webhook/intercom_client.py index d6b3bbe..bb1afe3 100644 --- a/packages/intercom/webhook/intercom_client.py +++ b/packages/intercom/webhook/intercom_client.py @@ -4,8 +4,9 @@ (notes, attribute updates). """ -import os import logging +import os + import requests logger = logging.getLogger(__name__) @@ -13,7 +14,7 @@ BASE_URL = "https://api.intercom.io" API_VERSION = "2.11" -_cached_admin_id = None +CALL_HTTP_TIMEOUT = (1, 2) def _headers(): @@ -129,21 +130,68 @@ def set_user_external_id(contact_id, external_id): # ── Conversation / contact helpers (call-timezone) ────────────────── -def _get_admin_id() -> str: - """Fetch the admin ID for the token owner (cached after first call).""" - global _cached_admin_id - if _cached_admin_id: - return _cached_admin_id - resp = requests.get(f"{BASE_URL}/me", headers=_headers(), timeout=10) +def get_webhook_admin_id() -> str: + """Return the deployment-pinned admin ID used to author webhook notes. + + Resolving ``/me`` inside a webhook invocation adds an avoidable network + round trip before Intercom's five-second acknowledgement deadline. The + manifest maps the already-pinned article author onto this function as + ``INTERCOM_WEBHOOK_ADMIN_ID``. + """ + admin_id = ( + os.environ.get("INTERCOM_WEBHOOK_ADMIN_ID", "") + or os.environ.get("INTERCOM_ARTICLE_AUTHOR_ID", "") + ).strip() + if ( + not admin_id + or not admin_id.isascii() + or not admin_id.isdigit() + or admin_id.startswith("0") + ): + raise RuntimeError("INTERCOM_WEBHOOK_ADMIN_ID must be a positive admin ID") + return admin_id + + +def get_conversation(conversation_id: str) -> dict: + """GET a conversation for durable call-note receipt reconciliation.""" + resp = requests.get( + f"{BASE_URL}/conversations/{conversation_id}", + headers=_headers(), + timeout=CALL_HTTP_TIMEOUT, + ) resp.raise_for_status() - _cached_admin_id = resp.json()["id"] - logger.info("Resolved token owner admin_id=%s", _cached_admin_id) - return _cached_admin_id + conversation = resp.json() + if not isinstance(conversation, dict): + raise ValueError("Intercom returned a malformed conversation") + return conversation + + +def conversation_contains_note_marker(conversation: dict, marker: str) -> bool: + """Return whether a retrieved conversation contains an exact note marker. + + Intercom includes the newest conversation parts in Retrieve Conversation. + A call receipt written on the first delivery is therefore available to the + one-minute retry without relying on ephemeral function memory. + """ + part_container = conversation.get("conversation_parts") + if not isinstance(part_container, dict): + raise ValueError("Intercom conversation is missing conversation_parts") + parts = part_container.get("conversation_parts") + if not isinstance(parts, list): + raise ValueError("Intercom conversation parts are malformed") + + for part in parts: + if not isinstance(part, dict): + raise ValueError("Intercom conversation part is malformed") + body = part.get("body") + if isinstance(body, str) and marker in body: + return True + return False def create_conversation_note(conversation_id: str, body: str) -> dict: """POST /conversations/{id}/parts — add an internal note to a conversation.""" - admin_id = _get_admin_id() + admin_id = get_webhook_admin_id() resp = requests.post( f"{BASE_URL}/conversations/{conversation_id}/parts", headers=_headers(), @@ -153,7 +201,7 @@ def create_conversation_note(conversation_id: str, body: str) -> dict: "admin_id": admin_id, "body": body, }, - timeout=10, + timeout=CALL_HTTP_TIMEOUT, ) resp.raise_for_status() logger.info("Created note on conversation %s", conversation_id) @@ -166,7 +214,7 @@ def update_contact_attributes(contact_id: str, attrs: dict) -> dict: f"{BASE_URL}/contacts/{contact_id}", headers=_headers(), json={"custom_attributes": attrs}, - timeout=10, + timeout=CALL_HTTP_TIMEOUT, ) if not resp.ok: logger.error("Intercom API error %s: %s", resp.status_code, resp.text) diff --git a/project.yml b/project.yml index 022bbf0..02b29b6 100644 --- a/project.yml +++ b/project.yml @@ -14,6 +14,7 @@ packages: - name: intercom environment: INTERCOM_ACCESS_TOKEN: ${INTERCOM_ACCESS_TOKEN} + INTERCOM_WEBHOOK_ADMIN_ID: "${INTERCOM_ARTICLE_AUTHOR_ID}" WEBHOOK_SECRET: ${WEBHOOK_SECRET} functions: - name: webhook