Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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

Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 0.1.8
version: 0.1.9
type: plugin
author: derekwang2002
name: call_e
Expand Down
7 changes: 6 additions & 1 deletion provider/call_e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
118 changes: 118 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions tests/test_plugin_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]


Expand All @@ -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)
74 changes: 74 additions & 0 deletions tools/create_goal_run.py
Original file line number Diff line number Diff line change
@@ -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))
56 changes: 56 additions & 0 deletions tools/create_goal_run.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading