diff --git a/README.md b/README.md index 086994b..54fbed5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ 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. +- `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. Phone calls are real-world side effects. Live calls require both `dry_run=false` and `confirm_live_call=true`. @@ -19,7 +21,7 @@ Phone calls are real-world side effects. Live calls require both `dry_run=false` - CALL-E API key ([create one in the CALL-E dashboard](https://dashboard.heycall-e.com/account/api-keys)) Install the Dify Plugin CLI by following the official Dify plugin documentation. -For request and response details, see the [CALL-E API reference](https://docs.heycall-e.com/#/api-reference). +For request and response details, see the [CALL-E API reference](https://test-docs.heycall-e.com/api-reference). ## Provider Credentials @@ -57,6 +59,16 @@ Fetches one call result from `GET /v1/calls/{call_id}` and returns masked, parse 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. +### Goal tools + +`list_goals` and `get_goal` read the published Goal catalog. They do not create a call. + +`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_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. + +Goal Run tools follow the same live-call boundary as Call tools: live execution requires both `dry_run=false` and `confirm_live_call=true`. + ## Safety Defaults - Requires E.164 destination phone numbers, for example `+15555550123`. diff --git a/manifest.yaml b/manifest.yaml index aaab3d7..28853b1 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -1,4 +1,4 @@ -version: 0.1.8 +version: 0.1.9 type: plugin author: derekwang2002 name: call_e diff --git a/provider/call_e.yaml b/provider/call_e.yaml index b7824a6..713cdf2 100644 --- a/provider/call_e.yaml +++ b/provider/call_e.yaml @@ -21,7 +21,7 @@ credentials_for_provider: en_US: >- Create an API key at https://dashboard.heycall-e.com/account/api-keys. It is used as a Bearer token for CALL-E API requests. API reference: - https://docs.heycall-e.com/#/api-reference + https://test-docs.heycall-e.com/api-reference base_url: type: text-input required: true @@ -36,6 +36,11 @@ tools: - tools/create_call.yaml - tools/get_call.yaml - tools/create_and_wait.yaml + - tools/list_goals.yaml + - tools/get_goal.yaml + - tools/create_goal_run.yaml + - tools/get_goal_run.yaml + - tools/create_goal_run_and_wait.yaml extra: python: source: provider/call_e.py diff --git a/pyproject.toml b/pyproject.toml index e9f8058..3cf2007 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "call-e-dify-plugin" -version = "0.1.8" +version = "0.1.9" 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 1b82d0d..77c86cc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,9 +4,12 @@ from utils.client import ( CalleClient, CalleApiError, + build_goal_run_payload, build_create_payload, + is_goal_run_resolved, mask_phone, normalize_base_url, + parse_goal_run_variables, parse_json_object, validate_e164, ) @@ -54,6 +57,121 @@ def test_build_create_payload_adds_source_metadata(): assert payload["metadata"]["source"] == "dify_call_e_plugin" +def test_build_goal_run_payload_keeps_only_documented_fields(): + assert build_goal_run_payload( + "+14155550100", + {"order_id": "ORD-8472", "priority": True}, + ) == { + "phone_number": "+14155550100", + "variables": {"order_id": "ORD-8472", "priority": True}, + } + + +def test_goal_run_variables_reject_nested_and_non_finite_values(): + with pytest.raises(ValueError, match="variables_json.order"): + parse_goal_run_variables('{"order":{"id":"ORD-8472"}}', field_name="variables_json") + with pytest.raises(ValueError, match="variables_json.score"): + 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 + + +def test_create_goal_run_posts_closed_payload_and_required_idempotency_header(monkeypatch): + class Response: + status_code = 201 + text = '{"id":"goal_run_1","result":null,"error":null}' + + def json(self): + return {"id": "goal_run_1", "result": None, "error": None} + + captured = {} + + def fake_request(method, url, headers, json, timeout, **kwargs): + captured.update( + method=method, + url=url, + headers=headers, + json=json, + timeout=timeout, + kwargs=kwargs, + ) + return Response() + + monkeypatch.setattr(client.requests, "request", fake_request) + + result = CalleClient(api_key="test_key", base_url="https://api.example.com").create_goal_run( + "goal_1", + {"phone_number": "+14155550100"}, + idempotency_key="delivery:ORD-8472:v1", + ) + + assert result == {"id": "goal_run_1", "result": None, "error": None} + assert captured == { + "method": "POST", + "url": "https://api.example.com/v1/goals/goal_1/runs", + "headers": { + "Accept": "application/json", + "Authorization": "Bearer test_key", + "Content-Type": "application/json", + "Idempotency-Key": "delivery:ORD-8472:v1", + }, + "json": {"phone_number": "+14155550100"}, + "timeout": 60, + "kwargs": {}, + } + + +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"}, + idempotency_key=" ", + ) + + +def test_goal_run_wait_keeps_polling_after_completed_until_result_is_persisted(monkeypatch): + api = CalleClient(api_key="test_key") + monkeypatch.setattr( + api, + "create_goal_run", + lambda *_args, **_kwargs: {"id": "goal_run_1", "result": None, "error": None}, + ) + responses = iter( + [ + {"id": "goal_run_1", "status": "completed", "result": None, "error": None}, + { + "id": "goal_run_1", + "status": "completed", + "result": {"confirmed": True}, + "error": None, + }, + ] + ) + monkeypatch.setattr(api, "get_goal_run", lambda *_args: next(responses)) + monkeypatch.setattr(client.time, "sleep", lambda _seconds: None) + + result, poll_count, timed_out = api.create_goal_run_and_wait( + "goal_1", + {"phone_number": "+14155550100"}, + "delivery:ORD-8472:v1", + poll_interval_seconds=1, + wait_timeout_seconds=60, + ) + + assert result["result"] == {"confirmed": True} + assert poll_count == 2 + assert timed_out is False + + def test_health_accepts_text_ok_response(monkeypatch): class Response: status_code = 200 diff --git a/tests/test_plugin_metadata.py b/tests/test_plugin_metadata.py index 5821079..a9eed13 100644 --- a/tests/test_plugin_metadata.py +++ b/tests/test_plugin_metadata.py @@ -46,7 +46,7 @@ def test_api_key_onboarding_links_are_user_visible(): for url in ( "https://dashboard.heycall-e.com/account/api-keys", - "https://docs.heycall-e.com/#/api-reference", + "https://test-docs.heycall-e.com/api-reference", ): assert url in api_key_help assert url in readme @@ -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.8" + assert manifest["version"] == "0.1.9" assert pyproject["project"]["version"] == manifest["version"] @@ -66,3 +66,22 @@ def test_marketplace_runtime_dependencies_are_declared(): assert "dify_plugin>=0.9.0" in requirements assert "requests>=2.32.0" in requirements + + +def test_provider_registers_goal_and_goal_run_tools(): + provider = load_yaml("provider/call_e.yaml") + assert set(provider["tools"]).issuperset( + { + "tools/list_goals.yaml", + "tools/get_goal.yaml", + "tools/create_goal_run.yaml", + "tools/get_goal_run.yaml", + "tools/create_goal_run_and_wait.yaml", + } + ) + + create_goal_run = load_yaml("tools/create_goal_run.yaml") + required_parameters = { + parameter["name"] for parameter in create_goal_run["parameters"] if parameter["required"] + } + assert {"goal_id", "phone_number", "idempotency_key"}.issubset(required_parameters) diff --git a/tools/create_goal_run.py b/tools/create_goal_run.py new file mode 100644 index 0000000..359d708 --- /dev/null +++ b/tools/create_goal_run.py @@ -0,0 +1,74 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from utils.client import ( + CalleApiError, + CalleClient, + build_goal_run_payload, + ensure_live_phone_allowed, + mask_phone, + normalize_base_url, + parse_bool, + parse_goal_run_variables, + redact_phone_fields, + validate_e164, +) + + +class CreateGoalRunTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + try: + goal_id = str(tool_parameters.get("goal_id") or "").strip() + idempotency_key = str(tool_parameters.get("idempotency_key") or "").strip() + 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: + 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) + preview = { + "dry_run": dry_run, + "live_call_created": False, + "will_create_live_call": (not dry_run) and confirm_live_call, + "goal_id": goal_id, + "idempotency_key": idempotency_key, + "masked_phone": mask_phone(phone_number), + "variables": variables, + } + if dry_run or not confirm_live_call: + yield self.create_text_message( + "Dry run preview only. No live CALL-E Goal Run was created. " + "Set dry_run=false and confirm_live_call=true to create one live call." + ) + yield self.create_json_message(preview) + return + + ensure_live_phone_allowed(phone_number) + credentials = self.runtime.credentials + client = CalleClient( + api_key=credentials.get("api_key"), + base_url=normalize_base_url(credentials.get("base_url")), + ) + goal_run = client.create_goal_run(goal_id, payload, idempotency_key=idempotency_key) + yield self.create_json_message( + { + **preview, + "dry_run": False, + "live_call_created": True, + "goal_run_id": goal_run.get("id"), + "goal_run": redact_phone_fields(goal_run), + } + ) + except (ValueError, CalleApiError) as error: + yield self.create_text_message(str(error)) diff --git a/tools/create_goal_run.yaml b/tools/create_goal_run.yaml new file mode 100644 index 0000000..cfb2f5b --- /dev/null +++ b/tools/create_goal_run.yaml @@ -0,0 +1,56 @@ +identity: + name: create_goal_run + author: derekwang2002 + label: + en_US: Create CALL-E Goal Run +description: + human: + en_US: Preview or create one phone-specific execution of a published CALL-E Goal. + llm: Preview or create one Goal Run. It can make a phone call only with dry_run=false and confirm_live_call=true. Reuse the same idempotency key when retrying the same business event. +parameters: + - name: goal_id + type: string + required: true + form: llm + label: + en_US: Goal ID + - name: phone_number + type: string + required: true + form: llm + label: + en_US: Phone Number + llm_description: Authorized recipient phone number in E.164 format, for example +14155550123. + - name: idempotency_key + type: string + required: true + 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. + - name: variables_json + type: string + required: false + form: llm + label: + en_US: Variables JSON + llm_description: Optional flat JSON object of finite string, number, or boolean Goal variables. + - name: dry_run + type: boolean + required: false + default: true + form: llm + label: + en_US: Dry Run + - name: confirm_live_call + type: boolean + required: false + default: false + form: llm + label: + en_US: Confirm Live Call +extra: + python: + source: tools/create_goal_run.py +output_schema: + type: object diff --git a/tools/create_goal_run_and_wait.py b/tools/create_goal_run_and_wait.py new file mode 100644 index 0000000..a59bf34 --- /dev/null +++ b/tools/create_goal_run_and_wait.py @@ -0,0 +1,100 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from utils.client import ( + CalleApiError, + CalleClient, + build_goal_run_payload, + ensure_live_phone_allowed, + mask_phone, + normalize_base_url, + parse_bool, + parse_goal_run_variables, + redact_phone_fields, + validate_e164, +) + + +def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int: + try: + parsed = int(float(value)) + except (TypeError, ValueError): + parsed = default + return max(minimum, min(maximum, parsed)) + + +class CreateGoalRunAndWaitTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + try: + goal_id = str(tool_parameters.get("goal_id") or "").strip() + idempotency_key = str(tool_parameters.get("idempotency_key") or "").strip() + 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.") + return + + poll_interval_seconds = _bounded_int( + tool_parameters.get("poll_interval_seconds"), default=5, minimum=1, maximum=60 + ) + wait_timeout_minutes = _bounded_int( + tool_parameters.get("wait_timeout_minutes"), default=15, minimum=1, maximum=60 + ) + 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) + preview = { + "dry_run": dry_run, + "live_call_created": False, + "will_create_live_call": (not dry_run) and confirm_live_call, + "goal_id": goal_id, + "idempotency_key": idempotency_key, + "masked_phone": mask_phone(phone_number), + "variables": variables, + "poll_interval_seconds": poll_interval_seconds, + "wait_timeout_minutes": wait_timeout_minutes, + } + if dry_run or not confirm_live_call: + yield self.create_text_message( + "Dry run preview only. No live CALL-E Goal Run 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) + return + + ensure_live_phone_allowed(phone_number) + credentials = self.runtime.credentials + client = CalleClient( + api_key=credentials.get("api_key"), + base_url=normalize_base_url(credentials.get("base_url")), + ) + goal_run, poll_count, timed_out = client.create_goal_run_and_wait( + goal_id, + payload, + idempotency_key, + poll_interval_seconds=poll_interval_seconds, + wait_timeout_seconds=wait_timeout_minutes * 60, + ) + result = { + **preview, + "dry_run": False, + "live_call_created": True, + "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"), + "goal_run": redact_phone_fields(goal_run), + } + if timed_out: + yield self.create_text_message( + "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: + 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 new file mode 100644 index 0000000..f60b501 --- /dev/null +++ b/tools/create_goal_run_and_wait.yaml @@ -0,0 +1,67 @@ +identity: + name: create_goal_run_and_wait + author: derekwang2002 + label: + 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. +parameters: + - name: goal_id + type: string + required: true + form: llm + label: + en_US: Goal ID + - name: phone_number + type: string + required: true + form: llm + label: + en_US: Phone Number + - name: idempotency_key + type: string + required: true + form: llm + label: + en_US: Idempotency Key + - name: variables_json + type: string + required: false + form: llm + label: + en_US: Variables JSON + - name: dry_run + type: boolean + required: false + default: true + form: llm + label: + en_US: Dry Run + - name: confirm_live_call + type: boolean + required: false + default: false + form: llm + label: + en_US: Confirm Live Call + - name: poll_interval_seconds + type: number + required: false + default: 5 + form: llm + label: + en_US: Poll Interval Seconds + - name: wait_timeout_minutes + type: number + required: false + default: 15 + form: llm + label: + en_US: Wait Timeout Minutes +extra: + python: + source: tools/create_goal_run_and_wait.py +output_schema: + type: object diff --git a/tools/get_goal.py b/tools/get_goal.py new file mode 100644 index 0000000..bf44497 --- /dev/null +++ b/tools/get_goal.py @@ -0,0 +1,21 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from utils.client import CalleApiError, CalleClient, normalize_base_url + + +class GetGoalTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + try: + credentials = self.runtime.credentials + client = CalleClient( + api_key=credentials.get("api_key"), + base_url=normalize_base_url(credentials.get("base_url")), + ) + response = client.get_goal(str(tool_parameters.get("goal_id") or "")) + yield self.create_json_message(response) + except CalleApiError as error: + yield self.create_text_message(str(error)) diff --git a/tools/get_goal.yaml b/tools/get_goal.yaml new file mode 100644 index 0000000..925eff2 --- /dev/null +++ b/tools/get_goal.yaml @@ -0,0 +1,21 @@ +identity: + name: get_goal + author: derekwang2002 + label: + en_US: Get CALL-E Goal +description: + human: + en_US: Fetch one published CALL-E Goal by ID. + llm: Read one CALL-E Goal using GET /v1/goals/{goal_id}. This operation does not create a phone call. +parameters: + - name: goal_id + type: string + required: true + form: llm + label: + en_US: Goal ID +extra: + python: + source: tools/get_goal.py +output_schema: + type: object diff --git a/tools/get_goal_run.py b/tools/get_goal_run.py new file mode 100644 index 0000000..cb332bc --- /dev/null +++ b/tools/get_goal_run.py @@ -0,0 +1,24 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from utils.client import CalleApiError, CalleClient, normalize_base_url, redact_phone_fields + + +class GetGoalRunTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + try: + credentials = self.runtime.credentials + client = CalleClient( + api_key=credentials.get("api_key"), + base_url=normalize_base_url(credentials.get("base_url")), + ) + response = client.get_goal_run( + str(tool_parameters.get("goal_id") or ""), + str(tool_parameters.get("goal_run_id") or ""), + ) + yield self.create_json_message(redact_phone_fields(response)) + except CalleApiError as error: + yield self.create_text_message(str(error)) diff --git a/tools/get_goal_run.yaml b/tools/get_goal_run.yaml new file mode 100644 index 0000000..97eee01 --- /dev/null +++ b/tools/get_goal_run.yaml @@ -0,0 +1,27 @@ +identity: + name: get_goal_run + author: derekwang2002 + label: + en_US: Get CALL-E Goal Run +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. +parameters: + - name: goal_id + type: string + required: true + form: llm + label: + en_US: Goal ID + - name: goal_run_id + type: string + required: true + form: llm + label: + en_US: Goal Run ID +extra: + python: + source: tools/get_goal_run.py +output_schema: + type: object diff --git a/tools/list_goals.py b/tools/list_goals.py new file mode 100644 index 0000000..60f38e9 --- /dev/null +++ b/tools/list_goals.py @@ -0,0 +1,29 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from utils.client import CalleApiError, CalleClient, normalize_base_url + + +def _bounded_limit(value: Any) -> int: + try: + return max(1, min(100, int(float(value)))) + except (TypeError, ValueError): + return 50 + + +class ListGoalsTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + try: + credentials = self.runtime.credentials + client = CalleClient( + 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"))) + 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 new file mode 100644 index 0000000..819f4c8 --- /dev/null +++ b/tools/list_goals.yaml @@ -0,0 +1,29 @@ +identity: + name: list_goals + author: derekwang2002 + label: + en_US: List CALL-E Goals +description: + human: + 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 + type: string + required: false + form: llm + label: + en_US: Cursor + - name: limit + type: number + required: false + default: 50 + form: llm + label: + en_US: Limit + llm_description: Page size from 1 to 100. +extra: + python: + source: tools/list_goals.py +output_schema: + type: object diff --git a/utils/client.py b/utils/client.py index d47af7a..18bf0c2 100644 --- a/utils/client.py +++ b/utils/client.py @@ -1,10 +1,12 @@ from __future__ import annotations import json +import math import re import time import uuid from typing import Any +from urllib.parse import quote import requests @@ -69,6 +71,18 @@ def parse_json_object(value: Any, *, field_name: str) -> dict[str, Any]: raise ValueError(f"{field_name} must be a JSON object.") +def parse_goal_run_variables(value: Any, *, field_name: str) -> dict[str, str | bool | int | float]: + variables = parse_json_object(value, field_name=field_name) + for key, item in variables.items(): + valid_scalar = isinstance(item, (str, bool, int, float)) + finite_number = not isinstance(item, float) or math.isfinite(item) + if not valid_scalar or not finite_number: + raise ValueError( + f"{field_name}.{key} must be a finite string, boolean, or number." + ) + return variables + + def mask_phone(phone: Any) -> str: value = str(phone or "").strip() if not value: @@ -85,6 +99,20 @@ def validate_e164(phone: Any) -> str: return value +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)} + 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 ensure_live_phone_allowed(phone: str) -> None: if phone in PLACEHOLDER_PHONES: raise ValueError( @@ -294,16 +322,26 @@ def _headers(self, *, idempotency_key: str | None = None) -> dict[str, str]: headers["Idempotency-Key"] = idempotency_key return headers - def _request(self, method: str, path: str, *, json_body: dict[str, Any] | None = None, timeout: int = 30) -> dict[str, Any]: + def _request( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + idempotency_key: str | None = None, + timeout: int = 30, + ) -> dict[str, Any]: url = f"{self.base_url}{path}" + request_kwargs: dict[str, Any] = { + "headers": self._headers(idempotency_key=idempotency_key), + "json": json_body, + "timeout": timeout, + } + if query: + request_kwargs["params"] = query try: - response = requests.request( - method, - url, - headers=self._headers(), - json=json_body, - timeout=timeout, - ) + response = requests.request(method, url, **request_kwargs) except requests.RequestException as error: raise CalleApiError(f"CALL-E API request failed: {error}") from error @@ -416,6 +454,67 @@ def get_call(self, call_id: str) -> dict[str, Any]: raise CalleApiError("call_id is required.") return self._request("GET", f"/v1/calls/{call_id}", timeout=60) + def list_goals(self, *, cursor: str | None = None, limit: int | None = None) -> dict[str, Any]: + query: dict[str, Any] = {} + if cursor: + query["cursor"] = str(cursor).strip() + if limit is not None: + query["limit"] = int(limit) + return self._request("GET", "/v1/goals", query=query, timeout=60) + + def get_goal(self, goal_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/goals/{_path_identifier(goal_id, 'goal_id')}", timeout=60) + + def create_goal_run( + self, + goal_id: str, + payload: dict[str, Any], + *, + idempotency_key: str, + ) -> dict[str, Any]: + stable_key = _required_idempotency_key(idempotency_key) + return self._request( + "POST", + f"/v1/goals/{_path_identifier(goal_id, 'goal_id')}/runs", + json_body=payload, + idempotency_key=stable_key, + timeout=60, + ) + + def get_goal_run(self, goal_id: str, goal_run_id: str) -> dict[str, Any]: + return self._request( + "GET", + f"/v1/goals/{_path_identifier(goal_id, 'goal_id')}/runs/" + f"{_path_identifier(goal_run_id, 'goal_run_id')}", + timeout=60, + ) + + def create_goal_run_and_wait( + self, + goal_id: str, + payload: dict[str, Any], + idempotency_key: str, + *, + poll_interval_seconds: int, + wait_timeout_seconds: int, + ) -> 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 + def create_and_wait( self, payload: dict[str, Any], @@ -447,3 +546,24 @@ def create_and_wait( def new_idempotency_key(prefix: str = "dify") -> str: return f"{prefix}_{uuid.uuid4().hex}" + + +def _path_identifier(value: Any, field_name: str) -> str: + identifier = str(value or "").strip() + if not identifier: + raise CalleApiError(f"{field_name} is required.") + return quote(identifier, safe="") + + +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.") + return 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, ""): + return str(candidate) + raise CalleApiError("CALL-E Goal Run create response did not include an id.")