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
2 changes: 1 addition & 1 deletion .difyignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ __pycache__/
.DS_Store
.env
*.difypkg
.git/
.git
.gitignore
.difyignore
*.egg-info/
Expand Down
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The plugin does not intentionally send Dify API keys, Dify workspace secrets, or

CALL-E may return call status, recipient status, attempt status, transcript turns, summaries, structured results, evidence, metadata, and raw call result fields.

The plugin masks phone numbers in text and JSON responses before returning data to Dify.
The plugin masks destination phone fields and phone fields in metadata and error details before returning data to Dify. Call business results retain their original values and types, including phone-named fields, in `result`, `structured_result`, and `raw_call_result.result`. Results, summaries, and transcript text may contain personal data and are not anonymized.

## Storage

Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ 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.
The plugin sends one top-level `phone` to `POST /v2/calls` with a closed, flat scalar `result_schema`. Leave **Result Schema JSON** blank to keep the built-in fields: `call_completed`, `outcome`, `summary`, and `next_action`, with optional observation fields. Both `create_call` and `create_and_wait` accept a custom schema instead. Include missing-answer rules in the task consistent with the requested 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.

Expand All @@ -73,6 +73,29 @@ Creates one call, then polls while `result_status=pending`. The waiter stops for

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.

### Custom result fields

Enter a JSON Schema object in **Result Schema JSON**. It replaces the built-in schema; the plugin does not add default business fields. Existing workflows that omit this input keep their current request and output paths.

The schema must have `type: "object"`, `additionalProperties: false`, and at most 32 flat fields. Each field supports `string`, `boolean`, `integer`, or `number`, an optional `description`, and an optional `enum`. Field names must match `[a-z][a-z0-9_]{0,63}`. `required` may list declared fields. Descriptions have a 512-character limit, enums allow 1–32 distinct values of the field's type, and the schema has a 32 KiB JSON limit. Nested objects, arrays, nullable fields, references, and other validation keywords are unsupported. Invalid schemas stop locally before any CALL-E request, including in dry run mode.

For unanswered questions, use an explicit value such as `unknown` and explain it in the task. Do not use a boolean when you need to distinguish “no” from “unknown.” Save the schema with the original task, phone, optional inputs, and idempotency key. Replays must use that same request; editing the schema under an existing key is not a retry.

Business fields remain in the returned result object, including `false`, `0`, `{}`, and `null`:

| Tool | Business fields | Readiness and telephone outcome |
| --- | --- | --- |
| `create_call`, `create_and_wait` | `result.result` | `result.result_status`, `result.call_outcome`, `result.error` |
| `get_call` | `result` (also `structured_result`) | `result_status`, `call_outcome`, `error` |

In Dify, bind the result object to a Code node to read a custom key. The variable picker lists the built-in fields; custom names are not added to it automatically. The complete `json` output is an array containing the tool response. A `completed` telephone outcome or an `available` result alone does not mean the recipient confirmed anything. Require the explicit business value as well. Keep empty, unavailable, and unknown answers in a separate branch.

#### Appointment confirmation example

Import [appointment-confirmation.yml](examples/appointment-confirmation.yml) into Dify after installing this plugin and configuring its credentials. The workflow requests `appointment_status` and `preferred_time`, reads those fields in a Code node, and uses an If/Else node to select **Confirmed**, **Reschedule requested**, or **Manual review**. It does not interpret the call summary with another model or book a replacement appointment.

The example starts in dry run mode. Supply `phone_number`, `appointment_time`, and a `call_request_id` saved by your upstream application. Inspect the preview, then set the tool's `dry_run=false` and `confirm_live_call=true` only for an authorized recipient. Preserve these inputs and the schema when rerunning that same business event. If waiting times out, retain `call_id` and resume with `get_call`; do not create a replacement call.

### Goal tools

`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.
Expand Down Expand Up @@ -105,7 +128,7 @@ See the [migration guide](https://docs.heycall-e.com/migration), [Calls contract

- Requires E.164 destination phone numbers, for example `+15555550123`.
- Rejects common placeholder numbers for live calls.
- Masks phone numbers in text and JSON responses.
- Masks destination phone fields and phone fields in metadata and error details. Call business results retain their original values and types; results and transcript text may contain personal data.
- Does not create recurring schedules.
- Does not expose API keys in logs or responses.
- Defaults to `dry_run=true`.
Expand Down
151 changes: 151 additions & 0 deletions examples/appointment-confirmation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
app:
name: CALL-E appointment confirmation
description: Confirm an appointment using custom result fields. Starts in dry run mode.
mode: workflow
icon: telephone_receiver
icon_type: emoji
icon_background: '#E8F0FE'
dependencies: []
kind: app
version: 0.7.0
workflow:
conversation_variables: []
environment_variables: []
features: {}
graph:
edges:
- {id: start-call, source: start, sourceHandle: source, target: call, targetHandle: target, type: custom, data: {sourceType: start, targetType: tool}}
- {id: call-fields, source: call, sourceHandle: source, target: fields, targetHandle: target, type: custom, data: {sourceType: tool, targetType: code}}
- {id: fields-route, source: fields, sourceHandle: source, target: route, targetHandle: target, type: custom, data: {sourceType: code, targetType: if-else}}
- {id: route-confirmed, source: route, sourceHandle: confirmed, target: confirmed, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: end}}
- {id: route-reschedule, source: route, sourceHandle: reschedule_requested, target: reschedule, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: end}}
- {id: route-review, source: route, sourceHandle: 'false', target: review, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: end}}
nodes:
- id: start
type: custom
position: {x: 0, y: 160}
data:
type: start
title: Saved appointment request
variables:
- {variable: phone_number, label: Authorized phone number, type: text-input, required: true, max_length: 16}
- {variable: appointment_time, label: Appointment time, type: text-input, required: true, max_length: 200}
- {variable: call_request_id, label: Saved call request ID, type: text-input, required: true, max_length: 255}
- id: call
type: custom
position: {x: 300, y: 160}
data:
type: tool
title: Confirm appointment
provider_type: builtin
provider_id: derekwang2002/call_e/call_e
provider_name: derekwang2002/call_e/call_e
provider_show_name: CALL-E
plugin_id: derekwang2002/call_e
tool_name: create_and_wait
tool_label: Create And Wait For CALL-E Call
tool_node_version: '2'
tool_configurations: {}
tool_parameters:
phone_number: {type: mixed, value: '{{#start.phone_number#}}'}
idempotency_key: {type: mixed, value: '{{#start.call_request_id#}}'}
dry_run: {type: constant, value: true}
confirm_live_call: {type: constant, value: false}
task:
type: mixed
value: >-
Speak English. Ask whether the recipient confirms the appointment for
{{#start.appointment_time#}}. Report appointment_status=confirmed only
after an explicit confirmation of that appointment. If they request a
different time, report reschedule_requested and ask for preferred_time;
do not book or promise a new appointment. If they do not answer clearly,
are not the intended recipient, or the call does not establish their
decision, report unknown. Set preferred_time to unknown unless they
explicitly provide a different time. Thank them and end politely.
result_schema:
type: mixed
value: >-
{"type":"object","additionalProperties":false,"required":["appointment_status","preferred_time"],"properties":{"appointment_status":{"type":"string","enum":["confirmed","reschedule_requested","unknown"],"description":"The recipient's explicit decision; unknown when it was not established."},"preferred_time":{"type":"string","description":"Requested alternative time, or unknown when none was provided."}}}
poll_interval_seconds: {type: constant, value: 5}
wait_timeout_minutes: {type: constant, value: 4}
- id: fields
type: custom
position: {x: 600, y: 160}
data:
type: code
title: Read appointment fields
code_language: python3
variables:
- {variable: tool_output, value_selector: [call, json]}
outputs:
route: {type: string, children: null}
appointment_status: {type: string, children: null}
preferred_time: {type: string, children: null}
code: |
def main(tool_output: list) -> dict:
envelope = tool_output[0] if tool_output and isinstance(tool_output[0], dict) else {}
call = envelope.get("result")
call = call if isinstance(call, dict) else {}
result = call.get("result")
result = result if isinstance(result, dict) else {}
status = result.get("appointment_status", "unknown")
if status not in ("confirmed", "reschedule_requested", "unknown"):
status = "unknown"
preferred_time = result.get("preferred_time", "unknown")
if not isinstance(preferred_time, str) or not preferred_time.strip():
preferred_time = "unknown"
ready = (
call.get("result_status") == "available"
and call.get("call_outcome") == "completed"
and call.get("error") is None
)
route = status if ready and status != "unknown" else "manual_review"
return {"route": route, "appointment_status": status, "preferred_time": preferred_time}
- id: route
type: custom
position: {x: 900, y: 160}
data:
type: if-else
title: Appointment decision
cases:
- case_id: confirmed
logical_operator: and
conditions:
- {id: is-confirmed, variable_selector: [fields, route], varType: string, comparison_operator: is, value: confirmed}
- case_id: reschedule_requested
logical_operator: and
conditions:
- {id: is-reschedule, variable_selector: [fields, route], varType: string, comparison_operator: is, value: reschedule_requested}
- id: confirmed
type: custom
position: {x: 1200, y: 0}
data:
type: end
title: Confirmed
outputs:
- {variable: route, value_selector: [fields, route], value_type: string}
- {variable: appointment_status, value_selector: [fields, appointment_status], value_type: string}
- {variable: preferred_time, value_selector: [fields, preferred_time], value_type: string}
- {variable: call_response, value_selector: [call, json], value_type: 'array[object]'}
- id: reschedule
type: custom
position: {x: 1200, y: 160}
data:
type: end
title: Reschedule requested
outputs:
- {variable: route, value_selector: [fields, route], value_type: string}
- {variable: appointment_status, value_selector: [fields, appointment_status], value_type: string}
- {variable: preferred_time, value_selector: [fields, preferred_time], value_type: string}
- {variable: call_response, value_selector: [call, json], value_type: 'array[object]'}
- id: review
type: custom
position: {x: 1200, y: 320}
data:
type: end
title: Manual review
outputs:
- {variable: route, value_selector: [fields, route], value_type: string}
- {variable: appointment_status, value_selector: [fields, appointment_status], value_type: string}
- {variable: preferred_time, value_selector: [fields, preferred_time], value_type: string}
- {variable: call_response, value_selector: [call, json], value_type: 'array[object]'}
2 changes: 1 addition & 1 deletion manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 0.2.2
version: 0.3.0
type: plugin
author: derekwang2002
name: call_e
Expand Down
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.2.2"
version = "0.3.0"
description = "CALL-E tools for Dify workflows and agents"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
50 changes: 50 additions & 0 deletions tests/test_appointment_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from pathlib import Path

import pytest
import yaml

from utils.client import parse_result_schema


EXAMPLE = yaml.safe_load((Path(__file__).resolve().parents[1] / "examples/appointment-confirmation.yml").read_text())
NODES = {node["id"]: node["data"] for node in EXAMPLE["workflow"]["graph"]["nodes"]}


def branch(call):
namespace = {}
exec(NODES["fields"]["code"], namespace)
return namespace["main"]([{"result": call}])


def test_example_uses_custom_fields_and_starts_without_dialing():
parameters = NODES["call"]["tool_parameters"]
schema = parse_result_schema(parameters["result_schema"]["value"])
assert schema["required"] == ["appointment_status", "preferred_time"]
assert parameters["dry_run"]["value"] is True
assert parameters["confirm_live_call"]["value"] is False
assert parameters["idempotency_key"]["value"] == "{{#start.call_request_id#}}"


@pytest.mark.parametrize("status", ["confirmed", "reschedule_requested", "unknown"])
def test_example_reads_business_fields_and_selects_the_matching_branch(status):
output = branch({"result_status": "available", "call_outcome": "completed", "error": None,
"result": {"appointment_status": status, "preferred_time": "Tuesday at 10"}})
assert output["appointment_status"] == status
assert output["preferred_time"] == "Tuesday at 10"
assert output["route"] == (status if status != "unknown" else "manual_review")
cases = {case["case_id"] for case in NODES["route"]["cases"]}
assert (output["route"] in cases) == (status != "unknown")


@pytest.mark.parametrize("call", [
None, {},
{"result_status": "available", "call_outcome": "completed", "result": {}},
{"result_status": "unavailable", "call_outcome": "completed", "result": None, "error": None},
{"result_status": "pending", "call_outcome": "completed", "result": {"appointment_status": "confirmed"}},
{"result_status": "available", "call_outcome": "failed", "result": {"appointment_status": "confirmed"}},
{"result_status": "available", "call_outcome": "completed", "error": {"code": "failed"},
"result": {"appointment_status": "confirmed"}},
{"result_status": "available", "call_outcome": "completed", "result": {"appointment_status": ["confirmed"]}},
])
def test_example_never_treats_missing_or_uncertain_evidence_as_confirmation(call):
assert branch(call)["route"] == "manual_review"
37 changes: 37 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from copy import deepcopy

import pytest

from utils import client
Expand Down Expand Up @@ -38,6 +40,41 @@ def test_mask_phone_hides_middle_digits():
assert mask_phone("+15555550123") == "+15****23"


def test_parse_call_result_preserves_business_values_and_masks_envelope_phones():
result = {
"phone_number_confirmed": False,
"phone_number_attempts": 0,
"phone": "unknown",
"callback_phone_number": "+15555550123",
}
call = {
"id": "call_1",
"phone": "+15555550123",
"result": result,
"metadata": {"phone_number": "+15555550123"},
"error": {"details": {"phone": "+15555550123"}},
}
original = deepcopy(call)

parsed = parse_call_result(call)

assert parsed["result"] == result
assert parsed["structured_result"] == result
assert parsed["raw_call_result"]["result"] == result
assert parsed["raw_call_result"]["phone"] == "+15****23"
assert parsed["metadata_returned"]["phone_number"] == "+15****23"
assert parsed["error"]["details"]["phone"] == "+15****23"
assert call == original


def test_parse_call_result_keeps_missing_raw_result_absent():
parsed = parse_call_result({"id": "call_1"})

assert parsed["result"] is None
assert parsed["structured_result"] is None
assert "result" not in parsed["raw_call_result"]


def test_parse_json_object_requires_object():
assert parse_json_object('{"lead_id":"lead_1"}', field_name="metadata_json") == {
"lead_id": "lead_1"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_plugin_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,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.2.2"
assert manifest["version"] == "0.3.0"
assert pyproject["project"]["version"] == manifest["version"]


Expand Down
Loading
Loading