From 7c486b0dcd5dac97c5005532e5ed48e5f2d179ea Mon Sep 17 00:00:00 2001 From: "J.Jason" <130959319+JJasonSun@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:54:39 +0800 Subject: [PATCH 1/3] feat: adapt Dify plugin to Calls API v2 --- README.md | 42 +++- manifest.yaml | 2 +- provider/call_e.py | 10 +- provider/call_e.yaml | 2 +- pyproject.toml | 2 +- tests/test_client.py | 219 ++++++++++++++++++-- tests/test_plugin_metadata.py | 10 +- tests/test_tools.py | 165 +++++++++++++++ tools/create_and_wait.py | 35 +++- tools/create_and_wait.yaml | 32 ++- tools/create_call.py | 24 ++- tools/create_call.yaml | 28 ++- tools/create_goal_run.py | 14 +- tools/create_goal_run.yaml | 14 +- tools/create_goal_run_and_wait.py | 21 +- tools/create_goal_run_and_wait.yaml | 22 +- tools/get_call.py | 3 +- tools/get_call.yaml | 39 +++- tools/get_goal.yaml | 2 + tools/get_goal_run.yaml | 6 +- tools/list_goals.py | 4 +- tools/list_goals.yaml | 9 +- utils/client.py | 309 ++++++++++++---------------- utils/messages.py | 12 ++ 24 files changed, 747 insertions(+), 279 deletions(-) create mode 100644 tests/test_tools.py create mode 100644 utils/messages.py diff --git a/README.md b/README.md index 9497b01..2fd536c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This plugin exposes safe outbound phone-call tools for CALL-E: - `create_call` previews or creates one outbound CALL-E call. - `get_call` fetches one CALL-E call result by call ID. -- `create_and_wait` previews or creates one outbound CALL-E call, then polls until the call reaches a terminal status or timeout. +- `create_and_wait` previews or creates one outbound CALL-E call, then waits for a final result or timeout. - `list_goals` and `get_goal` read published CALL-E Goals without starting a call. - `create_goal_run`, `get_goal_run`, and `create_goal_run_and_wait` execute and monitor one phone-specific published Goal. @@ -30,15 +30,15 @@ Configure these provider credentials in Dify: | Credential | Required | Description | | --- | --- | --- | | `api_key` | Yes | CALL-E API key. [Create a key](https://dashboard.heycall-e.com/account/api-keys); it is stored as a secret input in Dify. | -| `base_url` | Yes | CALL-E API base URL. Defaults to `https://api.heycall-e.com`. Do not include `/v1`. | +| `base_url` | Yes | CALL-E API base URL. Defaults to `https://api.heycall-e.com`. Do not include `/v1` or `/v2`. | When credentials are saved, the provider performs a non-mutating authentication probe: ```text -GET /v1/calls/call_dify_credential_probe_00000000000000000000000000000000 +GET /v2/calls/call_dify_credential_probe_00000000000000000000000000000000 ``` -The CALL-E API documents `GET /v1/calls/{call_id}` and the stable error code `not_found`. A documented `404 not_found` response means the API key was accepted and the probe call ID does not exist, so no call is created. `401 unauthorized` and `403 forbidden` are reported as credential configuration errors. +The provider accepts `200` or the documented `404 not_found` response as an authenticated probe. It rejects `401 unauthorized`, `403 forbidden`, redirects, and other errors. The probe does not create a call. The client does not follow redirects with credentials. ## Tools @@ -51,24 +51,48 @@ Creates one CALL-E call only when both live-call controls are set: Otherwise it returns a preview with the phone number masked. +Both Call creation tools require a business-stable `idempotency_key` of 1-255 printable ASCII characters, such as `order-8472-confirmation-attempt-1`. Save it with the original inputs before invoking the tool. Retrying the same key and unchanged inputs recovers the same Call; a new key means a new call. The plugin does not generate keys or retry creation automatically. + +The plugin sends one top-level `phone` to `POST /v2/calls` with a closed, flat scalar `result_schema`. The built-in schema requests `call_completed`, `outcome`, `summary`, and `next_action`, with optional observation fields. Include missing-answer rules in the task consistent with these fields. Leave `region` and `locale` blank for API inference, and keep them blank on replay even when the response includes inferred values. + +`live_call_created=true` reports an accepted resource, including an exact replay. It does not prove a new dial or a successful connection. HTTP creation errors return `error.code`, `error.message`, `error.details`, and the supplied key for recovery. + ### get_call -Fetches one call result from `GET /v1/calls/{call_id}` and returns masked, parsed status fields. +Fetches one result from `GET /v2/calls/{id}`. The parsed response includes `result`, `error`, `call_outcome`, `result_status`, and the top-level `transcript`. `summary` comes from `result.summary`; `structured_result` remains an alias for `result`, including `null` and `{}`. + +Call tools return the JSON response and emit named workflow variables for their declared outputs, so later Dify nodes can select `call_id` and `result` without parsing text. The default `json` output remains available for the complete response and recovery details. + +For existing Dify variable mappings, this plugin keeps `call_id` as the **API resource ID** used by `get_call`, also available as `id`. `billing_call_id` contains the API response's telephone `call_id`, which can be null. Use that field for Billing correlation, not for API reads. The redacted `raw_call_result` preserves the API field names. ### create_and_wait -Creates one call, then polls until a terminal status or timeout. This is the most convenient tool for Dify workflows that need one final result object. +Creates one call, then polls while `result_status=pending`. The waiter stops for `available`, `unavailable`, and `not_applicable`. A completed telephone execution can still have a pending result. A final unavailable result can have both `result=null` and `error=null`; it must not trigger an automatic redial. + +A wait timeout returns `timed_out=true`, the same `call_id`, and the supplied key. A polling error returns `accepted_resource` with the last saved ID; use `get_call` to resume. A network failure during creation can leave acceptance uncertain: retry only the original key and unchanged inputs. An expired Dify host execution also does not cancel the call. Set the host's plugin execution timeout to cover the requested wait, or use `create_call` followed by `get_call` in later workflow steps. ### Goal tools -`list_goals` and `get_goal` read the published Goal catalog. They do not create a call. +`list_goals` and `get_goal` read the published Goal catalog. They do not create a call. Pass the previous page's `next_cursor` unchanged as `after` for the next page. -`create_goal_run` runs one published Goal for one E.164 recipient. It accepts only the documented Goal Run body: `phone_number` and an optional flat `variables_json` object containing finite strings, numbers, or booleans. It requires a durable, business-stable `idempotency_key`; reuse that exact key and request when retrying a network failure. +`create_goal_run` runs one published Goal for one E.164 recipient. The Dify input remains `phone_number`; the client sends it as `phone` to `POST /v1/goals/{goal_id}/runs`. Optional `variables_json` must contain a flat object of finite strings, numbers, or booleans matching the published Goal's input schema. Voice settings and result schemas belong to the published Goal. Save and reuse the same `idempotency_key` and inputs after a network failure. -`create_goal_run_and_wait` polls until the API returns a non-null `result` or a non-null `error`. A Goal Run with `status=completed` and both fields null is still processing. A tool timeout does not cancel the provider-side run: use `get_goal_run` with the returned ID instead of creating a duplicate. +`create_goal_run_and_wait` uses the same `result_status` readiness rule as Calls and returns the transcript independently of result availability. A timeout does not cancel the Run: use `get_goal_run` with the returned `goal_run_id`. Polling errors retain the accepted resource; uncertain creation requires an exact replay with the original key. Goal Run tools follow the same live-call boundary as Call tools: live execution requires both `dry_run=false` and `confirm_live_call=true`. +## Upgrade from 0.1.x + +Version 0.2.0 moves new Calls to v2. Before upgrading a workflow: + +1. Add a durable `idempotency_key` to `create_call` and `create_and_wait`; do not derive it from each retry or workflow execution ID. +2. Check stored `region` and `locale` values. Older nodes may still have explicit `US` and `en-US`; remove them only when you want inference for **new** requests. Preserve original inputs for retries. +3. Replace dependencies on recipient/attempt status, completion confidence, or evidence fields. V2 does not expose these fields. Read business fields from `result`, telephone outcomes from `call_outcome`, and readiness from `result_status`. +4. Rename `list_goals.cursor` to `after`. Goal endpoints remain `/v1/goals/...`. +5. Keep historical v1 Call IDs with the original plugin/API interface. Reading an old ID through v2 does not migrate it. Do not create a replacement because an old ID cannot be read. + +See the [migration guide](https://docs.heycall-e.com/migration), [Calls contract](https://docs.heycall-e.com/calls), and [Goal Runs contract](https://docs.heycall-e.com/goal-runs). + ## Safety Defaults - Requires E.164 destination phone numbers, for example `+15555550123`. diff --git a/manifest.yaml b/manifest.yaml index 28853b1..0145008 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -1,4 +1,4 @@ -version: 0.1.9 +version: 0.2.0 type: plugin author: derekwang2002 name: call_e diff --git a/provider/call_e.py b/provider/call_e.py index 064e5ab..9a9e645 100644 --- a/provider/call_e.py +++ b/provider/call_e.py @@ -1,5 +1,4 @@ from typing import Any -from urllib.parse import urlparse from dify_plugin import ToolProvider from dify_plugin.errors.tool import ToolProviderCredentialValidationError @@ -10,18 +9,11 @@ class CallEProvider(ToolProvider): def _validate_credentials(self, credentials: dict[str, Any]) -> None: api_key = str(credentials.get("api_key") or "").strip() - base_url = normalize_base_url(credentials.get("base_url")) - if not api_key: raise ToolProviderCredentialValidationError("CALL-E API key is required.") - parsed_base_url = urlparse(base_url) - if parsed_base_url.scheme not in {"http", "https"} or not parsed_base_url.netloc: - raise ToolProviderCredentialValidationError( - "CALL-E API base URL must be an HTTP(S) URL." - ) - try: + base_url = normalize_base_url(credentials.get("base_url")) CalleClient(api_key=api_key, base_url=base_url).validate_credentials() except CalleApiError as error: raise ToolProviderCredentialValidationError(str(error)) from error diff --git a/provider/call_e.yaml b/provider/call_e.yaml index 91c5470..70814ae 100644 --- a/provider/call_e.yaml +++ b/provider/call_e.yaml @@ -30,7 +30,7 @@ credentials_for_provider: placeholder: en_US: https://api.heycall-e.com help: - en_US: CALL-E API host only. Do not include /v1. + en_US: CALL-E API host only. Do not include /v1 or /v2. default: https://api.heycall-e.com tools: - tools/create_call.yaml diff --git a/pyproject.toml b/pyproject.toml index 3cf2007..bfa6eae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "call-e-dify-plugin" -version = "0.1.9" +version = "0.2.0" description = "CALL-E tools for Dify workflows and agents" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_client.py b/tests/test_client.py index 77c86cc..86e155d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,10 +6,11 @@ CalleApiError, build_goal_run_payload, build_create_payload, - is_goal_run_resolved, + is_result_resolved, mask_phone, normalize_base_url, parse_goal_run_variables, + parse_call_result, parse_json_object, validate_e164, ) @@ -20,6 +21,7 @@ def test_normalize_base_url_strips_v1_suffix(): assert normalize_base_url("https://api.example.com/v1") == "https://api.example.com" + assert normalize_base_url("https://api.example.com/v2/") == "https://api.example.com" assert normalize_base_url("https://api.example.com/") == "https://api.example.com" @@ -52,7 +54,12 @@ def test_build_create_payload_adds_source_metadata(): locale="en-US", metadata={"lead_id": "lead_1"}, ) - assert payload["recipients"][0]["phones"] == ["+15555550123"] + assert payload["phone"] == "+15555550123" + assert "recipients" not in payload + assert "recipient_result_schema" not in payload + assert payload["result_schema"]["additionalProperties"] is False + assert all(field["type"] in {"string", "boolean", "number", "integer"} + for field in payload["result_schema"]["properties"].values()) assert payload["metadata"]["lead_id"] == "lead_1" assert payload["metadata"]["source"] == "dify_call_e_plugin" @@ -62,7 +69,7 @@ def test_build_goal_run_payload_keeps_only_documented_fields(): "+14155550100", {"order_id": "ORD-8472", "priority": True}, ) == { - "phone_number": "+14155550100", + "phone": "+14155550100", "variables": {"order_id": "ORD-8472", "priority": True}, } @@ -74,14 +81,15 @@ def test_goal_run_variables_reject_nested_and_non_finite_values(): parse_goal_run_variables('{"score": NaN}', field_name="variables_json") -def test_goal_run_resolution_requires_result_or_error(): - assert is_goal_run_resolved({"status": "completed", "result": None, "error": None}) is False - assert is_goal_run_resolved( - {"status": "completed", "result": {"confirmed": True}, "error": None} - ) is True - assert is_goal_run_resolved( - {"status": "failed", "result": None, "error": {"code": "no_answer"}} - ) is True +@pytest.mark.parametrize("status", ["available", "unavailable", "not_applicable"]) +def test_resolution_uses_result_status_even_with_null_result_and_error(status): + assert is_result_resolved({"status": "completed", "result_status": status, "result": None, "error": None}) + assert not is_result_resolved({"status": "completed", "result_status": "pending", "result": None, "error": None}) + + +def test_resolution_rejects_legacy_or_unrecognized_response(): + with pytest.raises(CalleApiError, match="result_status"): + is_result_resolved({"status": "completed", "result": {}}) def test_create_goal_run_posts_closed_payload_and_required_idempotency_header(monkeypatch): @@ -109,7 +117,7 @@ def fake_request(method, url, headers, json, timeout, **kwargs): result = CalleClient(api_key="test_key", base_url="https://api.example.com").create_goal_run( "goal_1", - {"phone_number": "+14155550100"}, + {"phone": "+14155550100"}, idempotency_key="delivery:ORD-8472:v1", ) @@ -123,9 +131,9 @@ def fake_request(method, url, headers, json, timeout, **kwargs): "Content-Type": "application/json", "Idempotency-Key": "delivery:ORD-8472:v1", }, - "json": {"phone_number": "+14155550100"}, + "json": {"phone": "+14155550100"}, "timeout": 60, - "kwargs": {}, + "kwargs": {"allow_redirects": False}, } @@ -133,7 +141,7 @@ def test_create_goal_run_requires_a_non_empty_idempotency_key(): with pytest.raises(CalleApiError, match="idempotency_key is required"): CalleClient(api_key="test_key").create_goal_run( "goal_1", - {"phone_number": "+14155550100"}, + {"phone": "+14155550100"}, idempotency_key=" ", ) @@ -143,14 +151,15 @@ def test_goal_run_wait_keeps_polling_after_completed_until_result_is_persisted(m monkeypatch.setattr( api, "create_goal_run", - lambda *_args, **_kwargs: {"id": "goal_run_1", "result": None, "error": None}, + lambda *_args, **_kwargs: {"id": "goal_run_1", "result_status": "pending", "result": None, "error": None}, ) responses = iter( [ - {"id": "goal_run_1", "status": "completed", "result": None, "error": None}, + {"id": "goal_run_1", "status": "completed", "result_status": "pending", "result": None, "error": None}, { "id": "goal_run_1", "status": "completed", + "result_status": "available", "result": {"confirmed": True}, "error": None, }, @@ -161,7 +170,7 @@ def test_goal_run_wait_keeps_polling_after_completed_until_result_is_persisted(m result, poll_count, timed_out = api.create_goal_run_and_wait( "goal_1", - {"phone_number": "+14155550100"}, + {"phone": "+14155550100"}, "delivery:ORD-8472:v1", poll_interval_seconds=1, wait_timeout_seconds=60, @@ -218,14 +227,14 @@ def fake_request(method, url, headers, timeout, **kwargs): assert calls == [ ( "GET", - f"https://api.example.com/v1/calls/{EXPECTED_CREDENTIAL_PROBE_CALL_ID}", + f"https://api.example.com/v2/calls/{EXPECTED_CREDENTIAL_PROBE_CALL_ID}", { "Accept": "application/json", "Authorization": "Bearer test_key", "Content-Type": "application/json", }, 10, - {}, + {"allow_redirects": False}, ) ] @@ -251,3 +260,173 @@ def fake_request(method, url, headers, timeout, **kwargs): with pytest.raises(CalleApiError, match="CALL-E API key is invalid or missing"): CalleClient(api_key="bad_key", base_url="https://api.example.com").validate_credentials() + + +def test_omitted_hints_stay_omitted_on_replay(): + payload = build_create_payload(phone_number="+15555550123", task="Speak English.", region="", locale="", metadata={}) + assert "region" not in payload + assert "locale" not in payload + + +@pytest.mark.parametrize("result", [None, {}, {"summary": "Test received."}]) +def test_parse_v2_result_preserves_null_empty_and_transcript(result): + call = { + "id": "call_api_1", "call_id": "billing_1", "status": "completed", + "call_outcome": "completed", "result_status": "available" if result is not None else "unavailable", + "result": result, "error": None, + "transcript": [{"speaker": "user", "offset_seconds": 1, "text": "Yes."}], + "metadata": {"lead_id": "lead_1"}, + } + parsed = parse_call_result(call, metadata_sent={"lead_id": "lead_1"}) + assert parsed["call_id"] == call["id"] + assert parsed["billing_call_id"] == call["call_id"] + assert parsed["result"] == parsed["structured_result"] == result + assert parsed["transcript"] == call["transcript"] + assert parsed["summary"] == (result or {}).get("summary") + assert parsed["answered"] is True + assert parsed["metadata_round_trip"]["all_requested_keys_returned"] + + +def test_no_answer_is_not_a_technical_failure_and_error_details_survive(): + parsed = parse_call_result({"id": "call_1", "status": "completed", "call_outcome": "no_answer", "result": None, "error": None}) + assert parsed["no_answer"] and not parsed["failed"] and not parsed["answered"] + error = {"code": "result_failed", "message": "Cannot save result.", "detail_code": "storage_error"} + parsed = parse_call_result({"id": "call_1", "call_id": None, "status": "completed", "error": error}) + assert parsed["billing_call_id"] is None + assert parsed["error"] == error + assert parsed["failure_code"] == "result_failed" and parsed["failed"] + + +@pytest.mark.parametrize("key", ["", " ", "x" * 256, "bad\nkey", "中文"]) +def test_calls_require_header_safe_stable_key(key, monkeypatch): + def unexpected_request(*args, **kwargs): + pytest.fail("Invalid key must not reach the network") + monkeypatch.setattr(client.requests, "request", unexpected_request) + with pytest.raises(CalleApiError, match="idempotency_key"): + CalleClient(api_key="test_key").create_call({}, idempotency_key=key) + + +def test_calls_use_v2_and_do_not_retry_network_failure(monkeypatch): + seen = [] + def timeout(method, url, **kwargs): + seen.append((method, url, kwargs)) + raise client.requests.Timeout("sensitive test_key transport data") + monkeypatch.setattr(client.requests, "request", timeout) + with pytest.raises(CalleApiError) as caught: + CalleClient(api_key="test_key").create_call({"phone": "+15555550123"}, idempotency_key="order-1") + assert len(seen) == 1 + assert seen[0][0:2] == ("POST", "https://api.heycall-e.com/v2/calls") + assert seen[0][2]["headers"]["Idempotency-Key"] == "order-1" + assert seen[0][2]["allow_redirects"] is False + assert "test_key" not in str(caught.value) + + +@pytest.mark.parametrize("kind", ["call", "goal"]) +def test_wait_preserves_accepted_id_after_poll_failure(kind, monkeypatch): + api = CalleClient(api_key="test_key") + resource = {"id": "saved_id", "result_status": "pending"} + monkeypatch.setattr(api, "create_call", lambda *a, **k: resource) + monkeypatch.setattr(api, "create_goal_run", lambda *a, **k: resource) + def fail(*args): + raise CalleApiError("Read failed.") + monkeypatch.setattr(api, "get_call", fail) + monkeypatch.setattr(api, "get_goal_run", fail) + monkeypatch.setattr(client.time, "sleep", lambda _: None) + with pytest.raises(CalleApiError) as caught: + if kind == "call": + api.create_and_wait({}, idempotency_key="key", poll_interval_seconds=1, wait_timeout_seconds=60) + else: + api.create_goal_run_and_wait("goal_1", {}, "key", poll_interval_seconds=1, wait_timeout_seconds=60) + assert caught.value.accepted_resource == resource + + +@pytest.mark.parametrize("kind", ["call", "goal"]) +@pytest.mark.parametrize("final_status", ["available", "unavailable", "not_applicable"]) +def test_both_waiters_stop_on_result_status(kind, final_status, monkeypatch): + api = CalleClient(api_key="test_key") + initial = {"id": "saved_id", "status": "completed", "result_status": "pending", "result": None, "error": None} + final = {**initial, "result_status": final_status} + monkeypatch.setattr(api, "create_call", lambda *a, **k: initial) + monkeypatch.setattr(api, "create_goal_run", lambda *a, **k: initial) + reads = [] + def read(*args): + reads.append(args) + return final + monkeypatch.setattr(api, "get_call", read) + monkeypatch.setattr(api, "get_goal_run", read) + monkeypatch.setattr(client.time, "sleep", lambda _: None) + if kind == "call": + result, polls, timed_out = api.create_and_wait({}, idempotency_key="key", poll_interval_seconds=1, wait_timeout_seconds=60) + else: + result, polls, timed_out = api.create_goal_run_and_wait("goal_1", {}, "key", poll_interval_seconds=1, wait_timeout_seconds=60) + assert result == final and polls == len(reads) == 1 and not timed_out + + +@pytest.mark.parametrize("kind", ["call", "goal"]) +def test_wait_timeout_returns_original_resource_without_redial(kind, monkeypatch): + api = CalleClient(api_key="test_key") + resource = {"id": "saved_id", "result_status": "pending"} + monkeypatch.setattr(api, "create_call", lambda *a, **k: resource) + monkeypatch.setattr(api, "create_goal_run", lambda *a, **k: resource) + if kind == "call": + result, polls, timed_out = api.create_and_wait({}, idempotency_key="key", poll_interval_seconds=1, wait_timeout_seconds=0) + else: + result, polls, timed_out = api.create_goal_run_and_wait("goal_1", {}, "key", poll_interval_seconds=1, wait_timeout_seconds=0) + assert result["id"] == "saved_id" and polls == 0 and timed_out + + +def test_goal_pagination_uses_after(monkeypatch): + api = CalleClient(api_key="test_key") + captured = {} + def request(method, path, **kwargs): + captured.update(kwargs) + return {"data": [], "next_cursor": None} + monkeypatch.setattr(api, "_request", request) + api.list_goals(after="opaque==", limit=1) + assert captured["query"] == {"after": "opaque==", "limit": 1} + + +@pytest.mark.parametrize("status,code", [(302, ""), (403, "forbidden"), (404, "wrong_code"), (500, "server_error")]) +def test_credential_probe_rejects_redirect_and_undocumented_errors(status, code, monkeypatch): + class Response: + status_code = status + text = "sensitive test_key body" + def json(self): + return {"error": {"code": code, "message": "sensitive test_key message"}} + monkeypatch.setattr(client.requests, "request", lambda *a, **k: Response()) + with pytest.raises(CalleApiError) as caught: + CalleClient(api_key="test_key").validate_credentials() + assert "test_key" not in str(caught.value) + + +@pytest.mark.parametrize("url", ["file:///tmp/key", "https://user:password@example.com", "https://api.example.com?token=key", "https://api.example.com#key"]) +def test_base_url_rejects_embedded_credentials_and_non_http_urls(url): + with pytest.raises(CalleApiError): + normalize_base_url(url) + + +def test_creation_does_not_mistake_billing_id_for_resource_id(monkeypatch): + api = CalleClient(api_key="test_key") + monkeypatch.setattr(api, "_request", lambda *a, **k: {"call_id": "billing_id"}) + with pytest.raises(CalleApiError, match="API resource id") as caught: + api.create_call({}, idempotency_key="order-1") + assert caught.value.accepted_resource == {"call_id": "billing_id"} + + +@pytest.mark.parametrize("invalid_json", [False, True]) +def test_malformed_create_response_stays_an_uncertain_submission(invalid_json, monkeypatch): + class Response: + status_code = 202 + text = "invalid" + def json(self): + if invalid_json: + raise ValueError("invalid JSON") + return [] + requests = [] + def request(*args, **kwargs): + requests.append(args) + return Response() + monkeypatch.setattr(client.requests, "request", request) + with pytest.raises(CalleApiError, match="non-JSON|non-object"): + CalleClient(api_key="test_key").create_call({}, idempotency_key="saved-key") + assert len(requests) == 1 diff --git a/tests/test_plugin_metadata.py b/tests/test_plugin_metadata.py index b222a85..53d438c 100644 --- a/tests/test_plugin_metadata.py +++ b/tests/test_plugin_metadata.py @@ -57,7 +57,7 @@ def test_release_version_is_synchronized(): with (ROOT / "pyproject.toml").open("rb") as pyproject_file: pyproject = tomllib.load(pyproject_file) - assert manifest["version"] == "0.1.9" + assert manifest["version"] == "0.2.0" assert pyproject["project"]["version"] == manifest["version"] @@ -85,3 +85,11 @@ def test_provider_registers_goal_and_goal_run_tools(): parameter["name"] for parameter in create_goal_run["parameters"] if parameter["required"] } assert {"goal_id", "phone_number", "idempotency_key"}.issubset(required_parameters) + + +def test_named_object_outputs_have_properties_so_dify_does_not_match_them_as_files(): + for path in ("tools/create_call.yaml", "tools/create_and_wait.yaml", "tools/get_call.yaml"): + for name, schema in load_yaml(path)["output_schema"]["properties"].items(): + types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if "object" in types: + assert schema.get("properties"), f"{path}.{name} needs named properties for Dify's type matcher" diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..dcf8c67 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,165 @@ +import json + +import pytest +from dify_plugin.config.config import DifyPluginEnv +from dify_plugin.core.plugin_registration import PluginRegistration + +from tools.create_and_wait import CreateAndWaitTool +from tools.create_call import CreateCallTool +from tools.create_goal_run import CreateGoalRunTool +from tools.create_goal_run_and_wait import CreateGoalRunAndWaitTool +from utils import client + + +CREATE_TOOLS = [CreateCallTool, CreateAndWaitTool, CreateGoalRunTool, CreateGoalRunAndWaitTool] +PARAMETERS = { + "phone_number": "+14155550100", + "task": "Speak English and confirm the test connection.", + "goal_id": "goal_1", + "idempotency_key": "order-8472-attempt-1", + "variables_json": '{"order_id":"8472"}', + "metadata_json": '{"lead_id":"lead_1"}', +} + + +def invoke(tool_class, parameters): + tool = tool_class.from_credentials({"api_key": "test_key"}) + return [message.model_dump(mode="json") for message in tool.invoke(parameters)] + + +def json_output(messages): + return next(message["message"]["json_object"] for message in messages if message["type"] == "json") + + +def test_dify_registers_all_eight_tools(): + registration = PluginRegistration(DifyPluginEnv()) + assert len(registration.tools_mapping["call_e"][2]) == 8 + + +@pytest.mark.parametrize("tool_class", CREATE_TOOLS) +@pytest.mark.parametrize("controls", [{}, {"dry_run": False}, {"confirm_live_call": True}]) +def test_live_call_requires_both_controls(tool_class, controls, monkeypatch): + def unexpected_request(*args, **kwargs): + pytest.fail("Preview must not access the network") + monkeypatch.setattr(client.requests, "request", unexpected_request) + result = json_output(invoke(tool_class, {**PARAMETERS, **controls})) + assert result["live_call_created"] is False + assert result["idempotency_key"] == PARAMETERS["idempotency_key"] + assert PARAMETERS["phone_number"] not in json.dumps(result) + + +@pytest.mark.parametrize("tool_class", CREATE_TOOLS) +def test_exact_replay_uses_the_supplied_key_and_body(tool_class, monkeypatch): + calls = [] + class Response: + status_code = 202 + text = "{}" + def json(self): + return { + "id": "saved_id", "status": "completed", "result_status": "unavailable", + "call_outcome": "no_answer", "result": None, "error": None, "transcript": [], + "metadata": {"source": "dify_call_e_plugin", "lead_id": "lead_1"}, + } + def request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return Response() + monkeypatch.setattr(client.requests, "request", request) + parameters = {**PARAMETERS, "dry_run": False, "confirm_live_call": True} + first = json_output(invoke(tool_class, parameters)) + replay = json_output(invoke(tool_class, parameters)) + assert len(calls) == 2 and calls[0] == calls[1] + assert calls[0][2]["headers"]["Idempotency-Key"] == parameters["idempotency_key"] + body = calls[0][2]["json"] + assert body["phone"] == parameters["phone_number"] + assert "phone_number" not in body + if tool_class in (CreateCallTool, CreateAndWaitTool): + assert calls[0][1].endswith("/v2/calls") + assert "region" not in body and "locale" not in body + assert body["result_schema"]["additionalProperties"] is False + assert first["call_id"] == replay["call_id"] == "saved_id" + assert first["result"]["result"] is None + assert first["result"]["result_status"] == "unavailable" + else: + assert calls[0][1].endswith("/v1/goals/goal_1/runs") + assert set(body) == {"phone", "variables"} + assert first["goal_run_id"] == replay["goal_run_id"] == "saved_id" + if tool_class in (CreateAndWaitTool, CreateGoalRunAndWaitTool): + assert first["poll_count"] == 0 and not first["timed_out"] + + +@pytest.mark.parametrize("tool_class", CREATE_TOOLS) +def test_creation_network_error_returns_recovery_key_without_retry(tool_class, monkeypatch): + calls = [] + def request(*args, **kwargs): + calls.append(args) + raise client.requests.Timeout("sensitive test_key transport detail") + monkeypatch.setattr(client.requests, "request", request) + messages = invoke(tool_class, {**PARAMETERS, "dry_run": False, "confirm_live_call": True}) + result = json_output(messages) + assert len(calls) == 1 + assert result["idempotency_key"] == PARAMETERS["idempotency_key"] + assert result["accepted_resource"] is None + assert "test_key" not in json.dumps(messages) + assert "original" in result["recovery"] + + +@pytest.mark.parametrize("tool_class", [CreateAndWaitTool, CreateGoalRunAndWaitTool]) +def test_poll_error_returns_accepted_id_and_original_key(tool_class, monkeypatch): + class Response: + status_code = 202 + text = "{}" + def json(self): + return {"id": "saved_id", "result_status": "pending"} + posts = [] + def request(method, url, **kwargs): + if method == "POST": + posts.append(url) + return Response() + raise client.requests.ConnectionError("read failed") + monkeypatch.setattr(client.requests, "request", request) + monkeypatch.setattr(client.time, "sleep", lambda _: None) + result = json_output(invoke(tool_class, {**PARAMETERS, "dry_run": False, "confirm_live_call": True})) + assert len(posts) == 1 + assert result["accepted_resource"]["id"] == "saved_id" + assert result["idempotency_key"] == PARAMETERS["idempotency_key"] + + +def test_creation_error_preserves_api_reason_and_missing_inputs(monkeypatch): + class Response: + status_code = 422 + text = "{}" + def json(self): + return {"error": {"code": "input_incomplete", "message": "Missing appointment time.", + "details": {"missing_inputs": ["Provide the appointment time."]}}} + monkeypatch.setattr(client.requests, "request", lambda *a, **k: Response()) + result = json_output(invoke(CreateCallTool, {**PARAMETERS, "dry_run": False, "confirm_live_call": True})) + assert result["error"]["code"] == "input_incomplete" + assert result["error"]["details"]["missing_inputs"] == ["Provide the appointment time."] + + +@pytest.mark.parametrize("tool_class", [CreateCallTool, CreateAndWaitTool]) +def test_named_call_outputs_are_emitted_as_dify_variables(tool_class): + messages = invoke(tool_class, PARAMETERS) + payload = json_output(messages) + variables = { + message["message"]["variable_name"]: message["message"]["variable_value"] + for message in messages if message["type"] == "variable" + } + assert variables == payload + + +def test_get_call_emits_v2_fields_as_named_variables(monkeypatch): + from tools.get_call import GetCallTool + + monkeypatch.setattr(client.CalleClient, "get_call", lambda *a: { + "id": "call_1", "call_id": None, "status": "completed", "result_status": "unavailable", + "result": None, "error": None, "call_outcome": "busy", "transcript": [], + }) + messages = invoke(GetCallTool, {"call_id": "call_1"}) + variables = { + message["message"]["variable_name"]: message["message"]["variable_value"] + for message in messages if message["type"] == "variable" + } + assert variables == json_output(messages) + assert variables["call_id"] == "call_1" and variables["billing_call_id"] is None + assert variables["result_status"] == "unavailable" and variables["result"] is None diff --git a/tools/create_and_wait.py b/tools/create_and_wait.py index b5ff761..c3acf83 100644 --- a/tools/create_and_wait.py +++ b/tools/create_and_wait.py @@ -8,16 +8,19 @@ CalleApiError, CalleClient, build_create_payload, + creation_error_result, ensure_live_phone_allowed, extract_call_id, mask_phone, - new_idempotency_key, normalize_base_url, parse_bool, parse_call_result, parse_json_object, + redact_phone_fields, + required_idempotency_key, validate_e164, ) +from utils.messages import result_messages def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int: @@ -30,7 +33,9 @@ def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int class CreateAndWaitTool(Tool): def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + idempotency_key = "" try: + idempotency_key = required_idempotency_key(tool_parameters.get("idempotency_key")) phone_number = validate_e164(tool_parameters.get("phone_number")) task = str(tool_parameters.get("task") or "").strip() if not task: @@ -40,8 +45,8 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag dry_run = parse_bool(tool_parameters.get("dry_run"), default=True) confirm_live_call = parse_bool(tool_parameters.get("confirm_live_call"), default=False) metadata = parse_json_object(tool_parameters.get("metadata_json"), field_name="metadata_json") - region = str(tool_parameters.get("region") or "US").strip() - locale = str(tool_parameters.get("locale") or "en-US").strip() + region = str(tool_parameters.get("region") or "").strip() + locale = str(tool_parameters.get("locale") or "").strip() poll_interval_seconds = _bounded_int( tool_parameters.get("poll_interval_seconds"), default=5, minimum=1, maximum=60 ) @@ -60,10 +65,11 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "live_call_created": False, "will_create_live_call": (not dry_run) and confirm_live_call, "masked_phone": mask_phone(phone_number), + "idempotency_key": idempotency_key, "region": region, "locale": locale, "task": task, - "metadata": payload["metadata"], + "metadata": redact_phone_fields(payload["metadata"]), "poll_interval_seconds": poll_interval_seconds, "wait_timeout_minutes": wait_timeout_minutes, } @@ -73,7 +79,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "Dry run preview only. No live CALL-E call was created. " "Set dry_run=false and confirm_live_call=true to create and wait for one live call." ) - yield self.create_json_message(preview) + yield from result_messages(self, preview) return ensure_live_phone_allowed(phone_number) @@ -82,7 +88,6 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag api_key=credentials.get("api_key"), base_url=normalize_base_url(credentials.get("base_url")), ) - idempotency_key = new_idempotency_key("dify_create_and_wait") call, poll_count, timed_out = client.create_and_wait( payload, poll_interval_seconds=poll_interval_seconds, @@ -103,11 +108,19 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag } status = parsed.get("call_status") or "unknown" - yield self.create_text_message( - f"CALL-E call completed polling. Call ID: {call_id or 'unknown'}. Status: {status}." - ) - yield self.create_json_message(result) - except (ValueError, CalleApiError) as error: + if timed_out: + yield self.create_text_message( + f"CALL-E polling timed out. Call ID: {call_id}. Use get_call to resume; do not create a replacement call." + ) + else: + yield self.create_text_message( + f"CALL-E call result ready. Call ID: {call_id}. Status: {status}. Result status: {parsed.get('result_status')}." + ) + yield from result_messages(self, result) + except CalleApiError as error: + yield self.create_text_message(str(error)) + yield from result_messages(self, creation_error_result(error, idempotency_key)) + except ValueError as error: yield self.create_text_message(str(error)) except Exception as error: yield self.create_text_message(f"Unexpected CALL-E create_and_wait error: {error}") diff --git a/tools/create_and_wait.yaml b/tools/create_and_wait.yaml index 59d4b55..c33b54f 100644 --- a/tools/create_and_wait.yaml +++ b/tools/create_and_wait.yaml @@ -5,8 +5,8 @@ identity: en_US: Create And Wait For CALL-E Call description: human: - en_US: Preview or create one CALL-E call, then poll until the call reaches a terminal status or timeout. - llm: Preview or create one CALL-E outbound phone call, then poll GET /v1/calls/{id} until terminal status or timeout. Live calls require dry_run=false and confirm_live_call=true. + en_US: Preview or create one CALL-E call, then wait until its result is final or the wait times out. + llm: Preview or create one CALL-E outbound phone call, then poll GET /v2/calls/{id} while result_status is pending. An unavailable result is final even with null result and error. Live calls require dry_run=false and confirm_live_call=true. parameters: - name: phone_number type: string @@ -36,6 +36,15 @@ parameters: human_description: en_US: When true, returns a masked preview without creating a call. llm_description: Set to false only when the user explicitly wants one live call. + - name: idempotency_key + type: string + required: true + form: llm + label: + en_US: Idempotency Key + human_description: + en_US: Stable business key for this call, saved before submission. + llm_description: Required 1-255 printable ASCII characters. Reuse the exact key and unchanged inputs after a timeout. Never generate a new key to retry the same call. - name: confirm_live_call type: boolean required: false @@ -49,23 +58,21 @@ parameters: - name: region type: string required: false - default: US form: llm label: en_US: Region human_description: - en_US: Region hint for CALL-E. - llm_description: Region hint, for example US or BR. + en_US: Optional region hint. CALL-E infers it from the phone when omitted. + llm_description: Optional region hint, for example US or BR. Omit to infer from the phone. - name: locale type: string required: false - default: en-US form: llm label: en_US: Locale human_description: - en_US: Locale hint for CALL-E. - llm_description: Locale hint, for example en-US or pt-BR. + en_US: Optional spoken language. CALL-E infers it from the task when omitted. + llm_description: Optional locale hint, for example en-US or pt-BR. Omit to infer from the task. - name: metadata_json type: string required: false @@ -109,9 +116,18 @@ output_schema: type: string call_id: type: string + idempotency_key: + type: string poll_count: type: number timed_out: type: boolean result: type: object + properties: + call_id: + type: string + result_status: + type: string + summary: + type: [string, "null"] diff --git a/tools/create_call.py b/tools/create_call.py index 9e2da37..1911b81 100644 --- a/tools/create_call.py +++ b/tools/create_call.py @@ -8,21 +8,26 @@ CalleApiError, CalleClient, build_create_payload, + creation_error_result, ensure_live_phone_allowed, extract_call_id, mask_phone, - new_idempotency_key, normalize_base_url, parse_bool, parse_call_result, parse_json_object, + redact_phone_fields, + required_idempotency_key, validate_e164, ) +from utils.messages import result_messages class CreateCallTool(Tool): def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + idempotency_key = "" try: + idempotency_key = required_idempotency_key(tool_parameters.get("idempotency_key")) phone_number = validate_e164(tool_parameters.get("phone_number")) task = str(tool_parameters.get("task") or "").strip() if not task: @@ -32,8 +37,8 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag dry_run = parse_bool(tool_parameters.get("dry_run"), default=True) confirm_live_call = parse_bool(tool_parameters.get("confirm_live_call"), default=False) metadata = parse_json_object(tool_parameters.get("metadata_json"), field_name="metadata_json") - region = str(tool_parameters.get("region") or "US").strip() - locale = str(tool_parameters.get("locale") or "en-US").strip() + region = str(tool_parameters.get("region") or "").strip() + locale = str(tool_parameters.get("locale") or "").strip() payload = build_create_payload( phone_number=phone_number, task=task, @@ -46,10 +51,11 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "live_call_created": False, "will_create_live_call": (not dry_run) and confirm_live_call, "masked_phone": mask_phone(phone_number), + "idempotency_key": idempotency_key, "region": region, "locale": locale, "task": task, - "metadata": payload["metadata"], + "metadata": redact_phone_fields(payload["metadata"]), } if dry_run or not confirm_live_call: @@ -57,7 +63,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "Dry run preview only. No live CALL-E call was created. " "Set dry_run=false and confirm_live_call=true to create one live call." ) - yield self.create_json_message(preview) + yield from result_messages(self, preview) return ensure_live_phone_allowed(phone_number) @@ -66,7 +72,6 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag api_key=credentials.get("api_key"), base_url=normalize_base_url(credentials.get("base_url")), ) - idempotency_key = new_idempotency_key("dify_create_call") call = client.create_call(payload, idempotency_key=idempotency_key) parsed = parse_call_result(call, metadata_sent=payload["metadata"]) call_id = extract_call_id(call) @@ -80,8 +85,11 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag } yield self.create_text_message(f"CALL-E call created. Call ID: {call_id or 'unknown'}.") - yield self.create_json_message(result) - except (ValueError, CalleApiError) as error: + yield from result_messages(self, result) + except CalleApiError as error: + yield self.create_text_message(str(error)) + yield from result_messages(self, creation_error_result(error, idempotency_key)) + except ValueError as error: yield self.create_text_message(str(error)) except Exception as error: yield self.create_text_message(f"Unexpected CALL-E create_call error: {error}") diff --git a/tools/create_call.yaml b/tools/create_call.yaml index 30a510f..5291a6c 100644 --- a/tools/create_call.yaml +++ b/tools/create_call.yaml @@ -36,6 +36,15 @@ parameters: human_description: en_US: When true, returns a masked preview without creating a call. llm_description: Set to false only when the user explicitly wants one live call. + - name: idempotency_key + type: string + required: true + form: llm + label: + en_US: Idempotency Key + human_description: + en_US: Stable business key for this call, saved before submission. + llm_description: Required 1-255 printable ASCII characters. Reuse the exact key and unchanged inputs after a timeout. Never generate a new key to retry the same call. - name: confirm_live_call type: boolean required: false @@ -49,23 +58,21 @@ parameters: - name: region type: string required: false - default: US form: llm label: en_US: Region human_description: - en_US: Region hint for CALL-E. - llm_description: Region hint, for example US or BR. + en_US: Optional region hint. CALL-E infers it from the phone when omitted. + llm_description: Optional region hint, for example US or BR. Omit to infer from the phone. - name: locale type: string required: false - default: en-US form: llm label: en_US: Locale human_description: - en_US: Locale hint for CALL-E. - llm_description: Locale hint, for example en-US or pt-BR. + en_US: Optional spoken language. CALL-E infers it from the task when omitted. + llm_description: Optional locale hint, for example en-US or pt-BR. Omit to infer from the task. - name: metadata_json type: string required: false @@ -89,5 +96,14 @@ output_schema: type: string call_id: type: string + idempotency_key: + type: string result: type: object + properties: + call_id: + type: string + result_status: + type: string + summary: + type: [string, "null"] diff --git a/tools/create_goal_run.py b/tools/create_goal_run.py index 359d708..82efcd2 100644 --- a/tools/create_goal_run.py +++ b/tools/create_goal_run.py @@ -8,21 +8,24 @@ CalleApiError, CalleClient, build_goal_run_payload, + creation_error_result, ensure_live_phone_allowed, mask_phone, normalize_base_url, parse_bool, parse_goal_run_variables, redact_phone_fields, + required_idempotency_key, validate_e164, ) class CreateGoalRunTool(Tool): def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + idempotency_key = "" try: goal_id = str(tool_parameters.get("goal_id") or "").strip() - idempotency_key = str(tool_parameters.get("idempotency_key") or "").strip() + idempotency_key = required_idempotency_key(tool_parameters.get("idempotency_key")) phone_number = validate_e164(tool_parameters.get("phone_number")) variables = parse_goal_run_variables( tool_parameters.get("variables_json"), field_name="variables_json" @@ -30,10 +33,6 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag if not goal_id: yield self.create_text_message("goal_id is required.") return - if not idempotency_key: - yield self.create_text_message("idempotency_key is required for Goal Run creation.") - return - dry_run = parse_bool(tool_parameters.get("dry_run"), default=True) confirm_live_call = parse_bool(tool_parameters.get("confirm_live_call"), default=False) payload = build_goal_run_payload(phone_number, variables) @@ -70,5 +69,8 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "goal_run": redact_phone_fields(goal_run), } ) - except (ValueError, CalleApiError) as error: + except CalleApiError as error: + yield self.create_text_message(str(error)) + yield self.create_json_message(creation_error_result(error, idempotency_key)) + except ValueError as error: yield self.create_text_message(str(error)) diff --git a/tools/create_goal_run.yaml b/tools/create_goal_run.yaml index cfb2f5b..7b05086 100644 --- a/tools/create_goal_run.yaml +++ b/tools/create_goal_run.yaml @@ -14,12 +14,16 @@ parameters: form: llm label: en_US: Goal ID + human_description: + en_US: ID of the published Goal to execute. - name: phone_number type: string required: true form: llm label: en_US: Phone Number + human_description: + en_US: Authorized recipient in E.164 format; sent as phone to the API. llm_description: Authorized recipient phone number in E.164 format, for example +14155550123. - name: idempotency_key type: string @@ -27,13 +31,17 @@ parameters: form: llm label: en_US: Idempotency Key - llm_description: Durable business key for one logical Goal Run. Reuse it only for retries of the exact same request. + human_description: + en_US: Stable business key for one Goal Run, saved before submission. + llm_description: Required 1-255 printable ASCII characters for one logical Goal Run. Reuse it only for retries of the exact same request. - name: variables_json type: string required: false form: llm label: en_US: Variables JSON + human_description: + en_US: Flat JSON object matching the published Goal input schema. llm_description: Optional flat JSON object of finite string, number, or boolean Goal variables. - name: dry_run type: boolean @@ -42,6 +50,8 @@ parameters: form: llm label: en_US: Dry Run + human_description: + en_US: Preview without creating a live call. - name: confirm_live_call type: boolean required: false @@ -49,6 +59,8 @@ parameters: form: llm label: en_US: Confirm Live Call + human_description: + en_US: Must be true with dry_run=false to create a live call. extra: python: source: tools/create_goal_run.py diff --git a/tools/create_goal_run_and_wait.py b/tools/create_goal_run_and_wait.py index a59bf34..5850950 100644 --- a/tools/create_goal_run_and_wait.py +++ b/tools/create_goal_run_and_wait.py @@ -8,12 +8,14 @@ CalleApiError, CalleClient, build_goal_run_payload, + creation_error_result, ensure_live_phone_allowed, mask_phone, normalize_base_url, parse_bool, parse_goal_run_variables, redact_phone_fields, + required_idempotency_key, validate_e164, ) @@ -28,15 +30,16 @@ def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int class CreateGoalRunAndWaitTool(Tool): def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + idempotency_key = "" try: goal_id = str(tool_parameters.get("goal_id") or "").strip() - idempotency_key = str(tool_parameters.get("idempotency_key") or "").strip() + idempotency_key = required_idempotency_key(tool_parameters.get("idempotency_key")) phone_number = validate_e164(tool_parameters.get("phone_number")) variables = parse_goal_run_variables( tool_parameters.get("variables_json"), field_name="variables_json" ) - if not goal_id or not idempotency_key: - yield self.create_text_message("goal_id and idempotency_key are required for Goal Run creation.") + if not goal_id: + yield self.create_text_message("goal_id is required for Goal Run creation.") return poll_interval_seconds = _bounded_int( @@ -87,8 +90,11 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "goal_run_id": goal_run.get("id"), "poll_count": poll_count, "timed_out": timed_out, - "result": goal_run.get("result"), - "error": goal_run.get("error"), + "result_status": goal_run.get("result_status"), + "call_outcome": goal_run.get("call_outcome"), + "transcript": redact_phone_fields(goal_run.get("transcript", [])), + "result": redact_phone_fields(goal_run.get("result")), + "error": redact_phone_fields(goal_run.get("error")), "goal_run": redact_phone_fields(goal_run), } if timed_out: @@ -96,5 +102,8 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "CALL-E Goal Run polling timed out. Use get_goal_run with the returned goal_run_id; do not create a duplicate Run." ) yield self.create_json_message(result) - except (ValueError, CalleApiError) as error: + except CalleApiError as error: + yield self.create_text_message(str(error)) + yield self.create_json_message(creation_error_result(error, idempotency_key)) + except ValueError as error: yield self.create_text_message(str(error)) diff --git a/tools/create_goal_run_and_wait.yaml b/tools/create_goal_run_and_wait.yaml index f60b501..22ea55f 100644 --- a/tools/create_goal_run_and_wait.yaml +++ b/tools/create_goal_run_and_wait.yaml @@ -5,8 +5,8 @@ identity: en_US: Create And Wait For CALL-E Goal Run description: human: - en_US: Preview or create one CALL-E Goal Run, then wait for its structured result or final error. - llm: Preview or create and wait for one Goal Run. It polls until result or error is non-null, because completed alone can still mean result processing. Live calls require dry_run=false and confirm_live_call=true. + en_US: Preview or create one CALL-E Goal Run, then wait until its result is final or the wait times out. + llm: Preview or create and wait for one Goal Run. Poll while result_status is pending; unavailable and not_applicable are final even with null result and error. Live calls require dry_run=false and confirm_live_call=true. parameters: - name: goal_id type: string @@ -14,24 +14,34 @@ parameters: form: llm label: en_US: Goal ID + human_description: + en_US: ID of the published Goal to execute. - name: phone_number type: string required: true form: llm label: en_US: Phone Number + human_description: + en_US: Authorized recipient in E.164 format; sent as phone to the API. - name: idempotency_key type: string required: true form: llm label: en_US: Idempotency Key + human_description: + en_US: Stable business key for one Goal Run, saved before submission. + llm_description: Required 1-255 printable ASCII characters. Retry only with the original key and unchanged inputs. - name: variables_json type: string required: false form: llm label: en_US: Variables JSON + human_description: + en_US: Flat JSON object matching the published Goal input schema. + llm_description: Optional flat JSON object of finite string, number, or boolean Goal variables. - name: dry_run type: boolean required: false @@ -39,6 +49,8 @@ parameters: form: llm label: en_US: Dry Run + human_description: + en_US: Preview without creating a live call. - name: confirm_live_call type: boolean required: false @@ -46,6 +58,8 @@ parameters: form: llm label: en_US: Confirm Live Call + human_description: + en_US: Must be true with dry_run=false to create a live call. - name: poll_interval_seconds type: number required: false @@ -53,6 +67,8 @@ parameters: form: llm label: en_US: Poll Interval Seconds + human_description: + en_US: Seconds between lookups, clamped between 1 and 60. - name: wait_timeout_minutes type: number required: false @@ -60,6 +76,8 @@ parameters: form: llm label: en_US: Wait Timeout Minutes + human_description: + en_US: Maximum wait in minutes, clamped between 1 and 60. A timeout does not cancel the call. extra: python: source: tools/create_goal_run_and_wait.py diff --git a/tools/get_call.py b/tools/get_call.py index 1839f4a..0f8c1e8 100644 --- a/tools/get_call.py +++ b/tools/get_call.py @@ -5,6 +5,7 @@ from dify_plugin.entities.tool import ToolInvokeMessage from utils.client import CalleApiError, CalleClient, normalize_base_url, parse_call_result +from utils.messages import result_messages class GetCallTool(Tool): @@ -25,7 +26,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag yield self.create_text_message( f"CALL-E call fetched. Status: {parsed.get('call_status') or 'unknown'}." ) - yield self.create_json_message(parsed) + yield from result_messages(self, parsed) except CalleApiError as error: yield self.create_text_message(str(error)) except Exception as error: diff --git a/tools/get_call.yaml b/tools/get_call.yaml index d82f00b..fa86026 100644 --- a/tools/get_call.yaml +++ b/tools/get_call.yaml @@ -5,8 +5,8 @@ identity: en_US: Get CALL-E Call description: human: - en_US: Fetch one CALL-E call by call ID. - llm: Fetch one CALL-E call by call ID and return parsed status, summary, transcript, metadata, and redacted raw result fields. + en_US: Fetch one Calls API v2 resource by its API ID. + llm: Fetch GET /v2/calls/{id} and return result_status, call_outcome, result, error, transcript, metadata, and redacted raw fields. Use the API resource ID, not the Billing call ID. Legacy IDs require the original interface. parameters: - name: call_id type: string @@ -15,8 +15,8 @@ parameters: label: en_US: Call ID human_description: - en_US: CALL-E call ID returned by create_call or create_and_wait. - llm_description: CALL-E call ID returned by create_call or create_and_wait. + en_US: API resource ID returned as call_id by this plugin's create tools, not the Billing ID. + llm_description: API resource ID returned as call_id by create_call or create_and_wait. Do not use billing_call_id or a legacy v1 ID. extra: python: source: tools/get_call.py @@ -27,13 +27,32 @@ output_schema: type: string call_status: type: string - recipient_status: - type: string - latest_attempt_status: + billing_call_id: + type: [string, "null"] + call_outcome: + type: [string, "null"] + result_status: type: string + error: + type: [object, "null"] + properties: + code: + type: string + message: + type: string summary: - type: string + type: [string, "null"] transcript: type: array - structured_result: - type: object + structured_result: &business_result + type: [object, "null"] + properties: + summary: + type: string + call_completed: + type: string + outcome: + type: string + next_action: + type: string + result: *business_result diff --git a/tools/get_goal.yaml b/tools/get_goal.yaml index 925eff2..0cbe58b 100644 --- a/tools/get_goal.yaml +++ b/tools/get_goal.yaml @@ -14,6 +14,8 @@ parameters: form: llm label: en_US: Goal ID + human_description: + en_US: ID of the published Goal to read. extra: python: source: tools/get_goal.py diff --git a/tools/get_goal_run.yaml b/tools/get_goal_run.yaml index 97eee01..938341e 100644 --- a/tools/get_goal_run.yaml +++ b/tools/get_goal_run.yaml @@ -6,7 +6,7 @@ identity: description: human: en_US: Fetch one CALL-E Goal Run and its current structured result or final error. - llm: Read a Goal Run using GET /v1/goals/{goal_id}/runs/{goal_run_id}. A completed status without result or error can still be processing. + llm: Read a Goal Run using GET /v1/goals/{goal_id}/runs/{goal_run_id}. Wait while result_status is pending; unavailable is final even with null result and error. This read does not start a call. parameters: - name: goal_id type: string @@ -14,12 +14,16 @@ parameters: form: llm label: en_US: Goal ID + human_description: + en_US: Goal ID used when creating the Run. - name: goal_run_id type: string required: true form: llm label: en_US: Goal Run ID + human_description: + en_US: API GoalRun.id returned at creation, not run_id or call_id. extra: python: source: tools/get_goal_run.py diff --git a/tools/list_goals.py b/tools/list_goals.py index 60f38e9..d23706d 100644 --- a/tools/list_goals.py +++ b/tools/list_goals.py @@ -22,8 +22,8 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag api_key=credentials.get("api_key"), base_url=normalize_base_url(credentials.get("base_url")), ) - cursor = str(tool_parameters.get("cursor") or "").strip() or None - response = client.list_goals(cursor=cursor, limit=_bounded_limit(tool_parameters.get("limit"))) + after = str(tool_parameters.get("after") or "").strip() or None + response = client.list_goals(after=after, limit=_bounded_limit(tool_parameters.get("limit"))) yield self.create_json_message(response) except CalleApiError as error: yield self.create_text_message(str(error)) diff --git a/tools/list_goals.yaml b/tools/list_goals.yaml index 819f4c8..72eaba7 100644 --- a/tools/list_goals.yaml +++ b/tools/list_goals.yaml @@ -8,12 +8,15 @@ description: en_US: List published CALL-E Goals available to this API key. llm: Read CALL-E Goals using GET /v1/goals. This operation does not create a phone call. parameters: - - name: cursor + - name: after type: string required: false form: llm label: - en_US: Cursor + en_US: After + human_description: + en_US: Opaque next_cursor from the previous page, passed unchanged. + llm_description: Pass the previous next_cursor unchanged as after. Do not construct a cursor from a Goal ID. - name: limit type: number required: false @@ -21,6 +24,8 @@ parameters: form: llm label: en_US: Limit + human_description: + en_US: Page size from 1 to 100. llm_description: Page size from 1 to 100. extra: python: diff --git a/utils/client.py b/utils/client.py index 18bf0c2..69ad6f4 100644 --- a/utils/client.py +++ b/utils/client.py @@ -4,9 +4,9 @@ import math import re import time -import uuid +from collections.abc import Callable from typing import Any -from urllib.parse import quote +from urllib.parse import quote, urlsplit import requests @@ -15,17 +15,7 @@ CREDENTIAL_PROBE_CALL_ID = "call_dify_credential_probe_00000000000000000000000000000000" E164_RE = re.compile(r"^\+[1-9]\d{7,14}$") PLACEHOLDER_PHONES = {"+15555550101", "+15555550102", "+14155550123"} -TERMINAL_STATUSES = { - "completed", - "failed", - "canceled", - "cancelled", - "no_answer", - "declined", - "voicemail", - "busy", - "expired", -} +FINAL_RESULT_STATUSES = {"available", "unavailable", "not_applicable"} class CalleApiError(Exception): @@ -36,17 +26,31 @@ def __init__( status_code: int | None = None, error_code: str | None = None, response_text: str | None = None, + details: dict[str, Any] | None = None, + accepted_resource: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.status_code = status_code self.error_code = error_code self.response_text = response_text + self.details = details or {} + self.accepted_resource = accepted_resource def normalize_base_url(value: Any) -> str: base_url = str(value or DEFAULT_BASE_URL).strip().rstrip("/") - if base_url.endswith("/v1"): + if base_url.endswith(("/v1", "/v2")): base_url = base_url[:-3].rstrip("/") + parsed = urlsplit(base_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise CalleApiError("CALL-E API base URL must be an HTTP(S) URL without credentials, query, or fragment.") return base_url or DEFAULT_BASE_URL @@ -103,14 +107,17 @@ def build_goal_run_payload( phone_number: str, variables: dict[str, str | bool | int | float], ) -> dict[str, Any]: - payload: dict[str, Any] = {"phone_number": validate_e164(phone_number)} + payload: dict[str, Any] = {"phone": validate_e164(phone_number)} if variables: payload["variables"] = variables return payload -def is_goal_run_resolved(goal_run: dict[str, Any]) -> bool: - return goal_run.get("result") is not None or goal_run.get("error") is not None +def is_result_resolved(resource: dict[str, Any]) -> bool: + status = resource.get("result_status") + if status not in FINAL_RESULT_STATUSES | {"pending"}: + raise CalleApiError("CALL-E response has a missing or unknown result_status; retain the original ID and request.") + return status != "pending" def ensure_live_phone_allowed(phone: str) -> None: @@ -135,10 +142,6 @@ def redact_phone_fields(value: Any) -> Any: return value -def normalize_status(value: Any) -> str: - return str(value or "").strip().lower().replace(" ", "_").replace("-", "_") - - def as_list(value: Any) -> list[Any]: return value if isinstance(value, list) else [] @@ -163,32 +166,28 @@ def _error_code_and_message(response: requests.Response) -> tuple[str, str]: return code, message -def _api_error(prefix: str, response: requests.Response) -> CalleApiError: +def _api_error(prefix: str, response: requests.Response, api_key: str) -> CalleApiError: code, message = _error_code_and_message(response) status = getattr(response, "status_code", None) - text = str(getattr(response, "text", ""))[:1000] + text = str(getattr(response, "text", "")).replace(api_key, "[redacted]")[:1000] parts = [f"{prefix}: HTTP {status}"] if code: parts.append(code) if message: - parts.append(message) - elif text: - parts.append(text) + parts.append(message.replace(api_key, "[redacted]")) return CalleApiError( ": ".join(parts), status_code=status, error_code=code or None, response_text=text, + details=redact_phone_fields(as_object(as_object(_response_json(response).get("error")).get("details"))), ) -def latest(values: list[Any]) -> Any: - return values[-1] if values else None - - def result_schema() -> dict[str, Any]: return { "type": "object", + "additionalProperties": False, "required": ["call_completed", "outcome", "summary", "next_action"], "properties": { "call_completed": {"type": "string", "enum": ["yes", "no", "partial", "unknown"]}, @@ -215,92 +214,57 @@ def build_create_payload( locale: str, metadata: dict[str, Any], ) -> dict[str, Any]: - schema = result_schema() - return { + payload = { "task": task, - "recipients": [ - { - "phones": [phone_number], - "region": region or "US", - "locale": locale or "en-US", - } - ], - "result_schema": schema, - "recipient_result_schema": schema, + "phone": validate_e164(phone_number), + "result_schema": result_schema(), "metadata": {**metadata, "source": "dify_call_e_plugin"}, } + if region: + payload["region"] = region + if locale: + payload["locale"] = locale + return payload def extract_call_id(call: dict[str, Any]) -> str: - candidates = [ - call.get("id"), - call.get("call_id"), - call.get("callId"), - call.get("uuid"), - as_object(call.get("data")).get("id"), - as_object(call.get("data")).get("call_id"), - as_object(call.get("data")).get("callId"), - as_object(call.get("call")).get("id"), - as_object(call.get("result")).get("id"), - ] - for candidate in candidates: - if candidate not in (None, ""): - return str(candidate) - return "" + # The API resource id is not the telephone/Billing call_id. + return str(call.get("id") or "") def parse_call_result(call: dict[str, Any], *, metadata_sent: dict[str, Any] | None = None) -> dict[str, Any]: - recipient = as_object(latest(as_list(call.get("recipients")))) - attempts = [as_object(attempt) for attempt in as_list(recipient.get("attempts"))] - latest_attempt = as_object(latest(attempts)) - transcript_turns = [] - for attempt in attempts: - transcript_turns.extend(as_list(attempt.get("transcript_turns"))) - - structured_result = as_object(recipient.get("structured_result")) or as_object(call.get("structured_result")) - raw_failure_code = latest_attempt.get("failure_code") or call.get("failure_code") - failure_code = normalize_status(raw_failure_code) - call_status = call.get("status") - recipient_status = recipient.get("status") - latest_attempt_status = latest_attempt.get("status") - failed_statuses = {"failed", "canceled", "cancelled", "no_answer", "declined", "busy", "expired"} - failed = ( - normalize_status(call_status) in failed_statuses - or normalize_status(recipient_status) in failed_statuses - or normalize_status(latest_attempt_status) in failed_statuses - ) - answered = latest_attempt_status == "completed" or recipient_status == "completed" + structured_result = call.get("result") + error = call.get("error") + call_outcome = call.get("call_outcome") metadata_returned = as_object(call.get("metadata")) metadata_sent = metadata_sent or {} - metadata_keys = sorted(set(metadata_sent.keys()) | {"lead_id", "notion_page_id", "company", "property", "campaign"}) + metadata_keys = sorted(metadata_sent) missing_or_different = [key for key in metadata_keys if metadata_sent.get(key) != metadata_returned.get(key)] return { + "id": extract_call_id(call), "call_id": extract_call_id(call), - "call_status": call_status, - "recipient_status": recipient_status, - "latest_attempt_status": latest_attempt_status, - "failure_code": raw_failure_code, - "failure_message": latest_attempt.get("failure_message") or call.get("failure_message"), - "answered": answered, - "no_answer": failure_code == "no_answer", - "busy": failure_code == "busy", - "failed": failed, - "metadata_returned": metadata_returned, + "billing_call_id": call.get("call_id"), + "call_status": call.get("status"), + "call_outcome": call_outcome, + "result_status": call.get("result_status"), + "error": redact_phone_fields(error), + "failure_code": as_object(error).get("code"), + "failure_message": as_object(error).get("message"), + "answered": call_outcome == "completed" if call_outcome is not None else None, + "no_answer": call_outcome == "no_answer", + "busy": call_outcome == "busy", + "failed": call.get("status") == "failed" or error is not None, + "metadata_returned": redact_phone_fields(metadata_returned), "metadata_round_trip": { "requested_keys": metadata_keys, "all_requested_keys_returned": len(missing_or_different) == 0, "missing_or_different_keys": missing_or_different, }, - "summary": structured_result.get("summary") - or recipient.get("summary") - or call.get("summary") - or latest_attempt.get("summary"), - "transcript": redact_phone_fields(transcript_turns), - "structured_result": structured_result, - "task_completed": call.get("task_completed"), - "completion_confidence": call.get("completion_confidence"), - "evidence": as_list(call.get("evidence")), + "summary": as_object(structured_result).get("summary"), + "transcript": redact_phone_fields(as_list(call.get("transcript"))), + "result": redact_phone_fields(structured_result), + "structured_result": redact_phone_fields(structured_result), "raw_call_result": redact_phone_fields(call), } @@ -337,16 +301,19 @@ def _request( "headers": self._headers(idempotency_key=idempotency_key), "json": json_body, "timeout": timeout, + "allow_redirects": False, } if query: request_kwargs["params"] = query try: response = requests.request(method, url, **request_kwargs) except requests.RequestException as error: - raise CalleApiError(f"CALL-E API request failed: {error}") from error + raise CalleApiError( + "CALL-E API network request failed. For creation, retry only with the original idempotency_key and unchanged inputs." + ) from error if response.status_code < 200 or response.status_code >= 300: - raise _api_error("CALL-E API request failed", response) + raise _api_error("CALL-E API request failed", response, self.api_key) if not response.text: return {} @@ -354,19 +321,22 @@ def _request( payload = response.json() except ValueError as error: raise CalleApiError("CALL-E API returned non-JSON response.") from error - return payload if isinstance(payload, dict) else {"data": payload} + if not isinstance(payload, dict): + raise CalleApiError("CALL-E API returned a non-object response; retain the original key and request.") + return payload def validate_credentials(self) -> dict[str, Any]: - url = f"{self.base_url}/v1/calls/{CREDENTIAL_PROBE_CALL_ID}" + url = f"{self.base_url}/v2/calls/{CREDENTIAL_PROBE_CALL_ID}" try: response = requests.request( "GET", url, headers=self._headers(), timeout=10, + allow_redirects=False, ) except requests.RequestException as error: - raise CalleApiError(f"Unable to reach CALL-E API while validating credentials: {error}") from error + raise CalleApiError("Unable to reach CALL-E API while validating credentials.") from error if response.status_code == 200: return { @@ -388,7 +358,6 @@ def validate_credentials(self) -> dict[str, Any]: "CALL-E API key is invalid or missing.", status_code=response.status_code, error_code=code or "unauthorized", - response_text=response.text[:1000], ) if response.status_code == 403 or code == "forbidden": @@ -396,10 +365,9 @@ def validate_credentials(self) -> dict[str, Any]: "CALL-E API key is valid but does not have access to this project or operation.", status_code=response.status_code, error_code=code or "forbidden", - response_text=response.text[:1000], ) - raise _api_error("CALL-E credential validation failed", response) + raise _api_error("CALL-E credential validation failed", response, self.api_key) def health(self) -> dict[str, Any]: url = f"{self.base_url}/health" @@ -409,14 +377,13 @@ def health(self) -> dict[str, Any]: url, headers=self._headers(), timeout=10, + allow_redirects=False, ) except requests.RequestException as error: - raise CalleApiError(f"CALL-E health check failed: {error}") from error + raise CalleApiError("Unable to reach CALL-E health check.") from error if response.status_code < 200 or response.status_code >= 300: - raise CalleApiError( - f"CALL-E health check failed: HTTP {response.status_code}: {response.text[:1000]}" - ) + raise _api_error("CALL-E health check failed", response, self.api_key) if not response.text: return {} @@ -428,36 +395,22 @@ def health(self) -> dict[str, Any]: return payload if isinstance(payload, dict) else {"data": payload} - def create_call(self, payload: dict[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]: - url = f"{self.base_url}/v1/calls" - try: - response = requests.post( - url, - headers=self._headers(idempotency_key=idempotency_key), - json=payload, - timeout=60, - ) - except requests.RequestException as error: - raise CalleApiError(f"CALL-E create call request failed: {error}") from error - - if response.status_code < 200 or response.status_code >= 300: - raise CalleApiError(f"CALL-E create call failed: HTTP {response.status_code}: {response.text[:1000]}") - try: - body = response.json() - except ValueError as error: - raise CalleApiError("CALL-E create call returned non-JSON response.") from error - return body if isinstance(body, dict) else {"data": body} + def create_call(self, payload: dict[str, Any], *, idempotency_key: str) -> dict[str, Any]: + call = self._request( + "POST", "/v2/calls", json_body=payload, + idempotency_key=required_idempotency_key(idempotency_key), timeout=60, + ) + if not extract_call_id(call): + raise CalleApiError("CALL-E create response did not include an API resource id.", accepted_resource=call) + return call def get_call(self, call_id: str) -> dict[str, Any]: - call_id = str(call_id or "").strip() - if not call_id: - raise CalleApiError("call_id is required.") - return self._request("GET", f"/v1/calls/{call_id}", timeout=60) + return self._request("GET", f"/v2/calls/{_path_identifier(call_id, 'call_id')}", timeout=60) - def list_goals(self, *, cursor: str | None = None, limit: int | None = None) -> dict[str, Any]: + def list_goals(self, *, after: str | None = None, limit: int | None = None) -> dict[str, Any]: query: dict[str, Any] = {} - if cursor: - query["cursor"] = str(cursor).strip() + if after: + query["after"] = after if limit is not None: query["limit"] = int(limit) return self._request("GET", "/v1/goals", query=query, timeout=60) @@ -472,7 +425,7 @@ def create_goal_run( *, idempotency_key: str, ) -> dict[str, Any]: - stable_key = _required_idempotency_key(idempotency_key) + stable_key = required_idempotency_key(idempotency_key) return self._request( "POST", f"/v1/goals/{_path_identifier(goal_id, 'goal_id')}/runs", @@ -500,20 +453,10 @@ def create_goal_run_and_wait( ) -> tuple[dict[str, Any], int, bool]: goal_run = self.create_goal_run(goal_id, payload, idempotency_key=idempotency_key) goal_run_id = _goal_run_id(goal_run) - poll_count = 0 - started = time.monotonic() - latest_goal_run = goal_run - - while not is_goal_run_resolved(latest_goal_run): - if time.monotonic() - started >= wait_timeout_seconds: - latest_goal_run = dict(latest_goal_run) - latest_goal_run["_dify_poll_timeout"] = True - return latest_goal_run, poll_count, True - time.sleep(max(1, poll_interval_seconds)) - poll_count += 1 - latest_goal_run = self.get_goal_run(goal_id, goal_run_id) - - return latest_goal_run, poll_count, False + return self._wait_for_result( + goal_run, lambda: self.get_goal_run(goal_id, goal_run_id), + poll_interval_seconds=poll_interval_seconds, wait_timeout_seconds=wait_timeout_seconds, + ) def create_and_wait( self, @@ -521,47 +464,67 @@ def create_and_wait( *, poll_interval_seconds: int, wait_timeout_seconds: int, - idempotency_key: str | None = None, + idempotency_key: str, ) -> tuple[dict[str, Any], int, bool]: call = self.create_call(payload, idempotency_key=idempotency_key) call_id = extract_call_id(call) - if not call_id: - raise CalleApiError("CALL-E create response did not include a recognized call ID.") + return self._wait_for_result( + call, lambda: self.get_call(call_id), + poll_interval_seconds=poll_interval_seconds, wait_timeout_seconds=wait_timeout_seconds, + ) + def _wait_for_result( + self, + resource: dict[str, Any], + read: Callable[[], dict[str, Any]], + *, + poll_interval_seconds: int, + wait_timeout_seconds: int, + ) -> tuple[dict[str, Any], int, bool]: poll_count = 0 - started = time.monotonic() - latest_call = call - - while normalize_status(latest_call.get("status")) not in TERMINAL_STATUSES: - if time.monotonic() - started >= wait_timeout_seconds: - latest_call = dict(latest_call) - latest_call["_dify_poll_timeout"] = True - return latest_call, poll_count, True - time.sleep(max(1, poll_interval_seconds)) - poll_count += 1 - latest_call = self.get_call(call_id) - - return latest_call, poll_count, False - - -def new_idempotency_key(prefix: str = "dify") -> str: - return f"{prefix}_{uuid.uuid4().hex}" + deadline = time.monotonic() + wait_timeout_seconds + try: + while not is_result_resolved(resource): + remaining = deadline - time.monotonic() + if remaining <= 0: + return {**resource, "_dify_poll_timeout": True}, poll_count, True + time.sleep(min(max(1, poll_interval_seconds), remaining)) + if time.monotonic() >= deadline: + return {**resource, "_dify_poll_timeout": True}, poll_count, True + resource = read() + poll_count += 1 + except CalleApiError as error: + error.accepted_resource = resource + raise + return resource, poll_count, False def _path_identifier(value: Any, field_name: str) -> str: identifier = str(value or "").strip() - if not identifier: + if not identifier or identifier in {".", ".."}: raise CalleApiError(f"{field_name} is required.") return quote(identifier, safe="") -def _required_idempotency_key(value: Any) -> str: +def required_idempotency_key(value: Any) -> str: key = str(value or "").strip() - if not key: - raise CalleApiError("idempotency_key is required for Goal Run creation.") + if not key or len(key) > 255 or any(ord(char) < 32 or ord(char) > 126 for char in key): + raise CalleApiError("idempotency_key is required: use 1-255 printable ASCII characters for one business event.") return key +def creation_error_result(error: CalleApiError, idempotency_key: str) -> dict[str, Any]: + return { + "idempotency_key": idempotency_key, + "accepted_resource": redact_phone_fields(error.accepted_resource), + "error": { + "code": error.error_code, "message": str(error), + "http_status": error.status_code, "details": error.details, + }, + "recovery": "If accepted_resource includes an id, read it with get_call or get_goal_run. Otherwise acceptance may be uncertain. Retry creation only with the original key and unchanged inputs; do not generate a new key.", + } + + def _goal_run_id(goal_run: dict[str, Any]) -> str: for candidate in (goal_run.get("id"), goal_run.get("goal_run_id"), as_object(goal_run.get("data")).get("id")): if candidate not in (None, ""): diff --git a/utils/messages.py b/utils/messages.py new file mode 100644 index 0000000..e6a4490 --- /dev/null +++ b/utils/messages.py @@ -0,0 +1,12 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + + +def result_messages(tool: Tool, result: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + yield tool.create_json_message(result) + # Dify populates named workflow outputs from variable messages, not JSON messages. + for name, value in result.items(): + yield tool.create_variable_message(name, value) From 08646c2bdf2fcd0b439df96df6f19189ca376098 Mon Sep 17 00:00:00 2001 From: "J.Jason" <130959319+JJasonSun@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:00 +0800 Subject: [PATCH 2/3] fix: clarify idempotency key configuration errors --- README.md | 4 ++++ tests/test_tools.py | 17 +++++++++++++++++ utils/client.py | 15 +++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2fd536c..2935c5c 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ Version 0.2.0 moves new Calls to v2. Before upgrading a workflow: 4. Rename `list_goals.cursor` to `after`. Goal endpoints remain `/v1/goals/...`. 5. Keep historical v1 Call IDs with the original plugin/API interface. Reading an old ID through v2 does not migrate it. Do not create a replacement because an old ID cannot be read. +In Dify, open the CALL-E node and bind **Idempotency Key** to an upstream field saved with your order or CRM event, such as `call_request_id`. For order 8472's first confirmation call, store `order-8472-confirmation-attempt-1` before running the workflow. Supply that same value and unchanged call inputs when retrying or rerunning the event. Store `order-8472-confirmation-attempt-2` only after deciding to place a second call. + +The API requires this key to prevent duplicate calls. The plugin cannot distinguish a retry from an intended second call using the phone number and task alone. Do not use one fixed key for all calls or generate a new key on each workflow execution. Missing or invalid keys stop locally without sending a CALL-E request; fill in the node's key before submitting. + See the [migration guide](https://docs.heycall-e.com/migration), [Calls contract](https://docs.heycall-e.com/calls), and [Goal Runs contract](https://docs.heycall-e.com/goal-runs). ## Safety Defaults diff --git a/tests/test_tools.py b/tests/test_tools.py index dcf8c67..980b082 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -48,6 +48,23 @@ def unexpected_request(*args, **kwargs): assert PARAMETERS["phone_number"] not in json.dumps(result) +@pytest.mark.parametrize("tool_class", CREATE_TOOLS) +@pytest.mark.parametrize("key", [None, " ", "event\n1"]) +def test_invalid_key_reports_local_configuration_error_without_submission(tool_class, key, monkeypatch): + def unexpected_request(*args, **kwargs): + pytest.fail("An invalid idempotency key must not access the network") + monkeypatch.setattr(client.requests, "request", unexpected_request) + messages = invoke(tool_class, { + **PARAMETERS, "idempotency_key": key, "dry_run": False, "confirm_live_call": True, + }) + result = json_output(messages) + assert result["accepted_resource"] is None + assert "No CALL-E request was sent" in result["error"]["message"] + assert "Idempotency Key" in result["recovery"] + assert "saved business event ID" in result["recovery"] + assert "uncertain" not in result["recovery"] + + @pytest.mark.parametrize("tool_class", CREATE_TOOLS) def test_exact_replay_uses_the_supplied_key_and_body(tool_class, monkeypatch): calls = [] diff --git a/utils/client.py b/utils/client.py index 69ad6f4..6f87e9d 100644 --- a/utils/client.py +++ b/utils/client.py @@ -509,7 +509,10 @@ def _path_identifier(value: Any, field_name: str) -> str: def required_idempotency_key(value: Any) -> str: key = str(value or "").strip() if not key or len(key) > 255 or any(ord(char) < 32 or ord(char) > 126 for char in key): - raise CalleApiError("idempotency_key is required: use 1-255 printable ASCII characters for one business event.") + raise CalleApiError( + "idempotency_key is required: use 1-255 printable ASCII characters for one business event. " + "No CALL-E request was sent." + ) return key @@ -521,7 +524,15 @@ def creation_error_result(error: CalleApiError, idempotency_key: str) -> dict[st "code": error.error_code, "message": str(error), "http_status": error.status_code, "details": error.details, }, - "recovery": "If accepted_resource includes an id, read it with get_call or get_goal_run. Otherwise acceptance may be uncertain. Retry creation only with the original key and unchanged inputs; do not generate a new key.", + "recovery": ( + "Set the tool's Idempotency Key to a saved business event ID, " + "for example order-8472-confirmation-attempt-1, then submit. " + "Keep this key and the original inputs for retries." + if not idempotency_key + else "If accepted_resource includes an id, read it with get_call or get_goal_run. " + "Otherwise acceptance may be uncertain. Retry creation only with the original key " + "and unchanged inputs; do not generate a new key." + ), } From 89ac98097755310638761bcc659f9bebdbf5f2f5 Mon Sep 17 00:00:00 2001 From: "J.Jason" <130959319+JJasonSun@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:25:47 +0800 Subject: [PATCH 3/3] docs: explain Dify cross-run idempotency limits --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2935c5c..767b250 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,11 @@ Version 0.2.0 moves new Calls to v2. Before upgrading a workflow: 4. Rename `list_goals.cursor` to `after`. Goal endpoints remain `/v1/goals/...`. 5. Keep historical v1 Call IDs with the original plugin/API interface. Reading an old ID through v2 does not migrate it. Do not create a replacement because an old ID cannot be read. -In Dify, open the CALL-E node and bind **Idempotency Key** to an upstream field saved with your order or CRM event, such as `call_request_id`. For order 8472's first confirmation call, store `order-8472-confirmation-attempt-1` before running the workflow. Supply that same value and unchanged call inputs when retrying or rerunning the event. Store `order-8472-confirmation-attempt-2` only after deciding to place a second call. +In Dify, bind **Idempotency Key** to an upstream field saved with the call request, such as `call_request_id`. The value can be a UUID generated once by your upstream application; it does not have to be an order number or something a user types. Save it before invoking CALL-E and keep it when retrying the node or rerunning the same business event. Generate a new value only for an intentionally new call. -The API requires this key to prevent duplicate calls. The plugin cannot distinguish a retry from an intended second call using the phone number and task alone. Do not use one fixed key for all calls or generate a new key on each workflow execution. Missing or invalid keys stop locally without sending a CALL-E request; fill in the node's key before submitting. +For order 8472's first confirmation call, a saved value such as `order-8472-confirmation-attempt-1` works. Supply that same value and unchanged call inputs on retries. Use `order-8472-confirmation-attempt-2` only after deciding to place a second call. Do not use one fixed key for all calls or generate a fresh UUID on each workflow execution. Missing or invalid keys stop locally without sending a CALL-E request. + +Dify provides [plugin KV storage](https://docs.dify.ai/en/develop-plugin/features-and-specs/plugin-types/persistent-storage-kv), but its tool context does not provide a durable logical-call identifier across separate Workflow runs. A plugin session ID changes per invocation, and an app or conversation ID can cover multiple calls. Storage alone therefore cannot tell the plugin which saved key to reuse. This version requires the upstream identifier rather than guessing from the phone number and task. It does not store or generate keys in plugin KV storage. See the [migration guide](https://docs.heycall-e.com/migration), [Calls contract](https://docs.heycall-e.com/calls), and [Goal Runs contract](https://docs.heycall-e.com/goal-runs).