pipecat-piopiy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pipecat-piopiy might be problematic. Click here for more details.

@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TeleCMI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipecat-piopiy
3
+ Version: 0.1.0
4
+ Summary: Piopiy (TeleCMI) telephony for Pipecat voice agents: take calls, transfer them, hang up
5
+ Author-email: TeleCMI <support@telecmi.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://piopiy.com
8
+ Project-URL: Repository, https://github.com/telecmi/pipecat-piopiy
9
+ Project-URL: Documentation, https://github.com/telecmi/pipecat-piopiy#readme
10
+ Keywords: pipecat,voice,telephony,sip,piopiy,telecmi
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Communications :: Telephony
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pipecat-ai[livekit]<2,>=1.8.0
23
+ Requires-Dist: piopiy-agent>=1.0.0
24
+ Requires-Dist: aiohttp>=3.9
25
+ Provides-Extra: example
26
+ Requires-Dist: pipecat-ai[deepgram,livekit,openai,silero]<2,>=1.8.0; extra == "example"
27
+ Requires-Dist: python-dotenv>=1.0; extra == "example"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
31
+ Requires-Dist: ruff>=0.5; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # pipecat-piopiy
35
+
36
+ Piopiy telephony for [Pipecat](https://github.com/pipecat-ai/pipecat) voice
37
+ agents. Your Pipecat bot takes real phone calls on the Piopiy platform by
38
+ TeleCMI: calls the Piopiy API places, calls arriving on your numbers, and
39
+ calls arriving from your PBX over SIP Connect. Mid-call it can transfer the
40
+ caller to a human, warm or blind, and hang up.
41
+
42
+ Piopiy bridges each call into a LiveKit room and hands your worker the room.
43
+ Media runs through Pipecat's own `LiveKitTransport`; this package supplies the
44
+ rest - taking the call, knowing who is calling, acting on the call, and hearing
45
+ about transfers.
46
+
47
+ **Tested with Pipecat v1.8.1.** Community-maintained by
48
+ [TeleCMI](https://telecmi.com); not part of the Pipecat core.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install pipecat-piopiy
54
+ # for the example, with Deepgram + OpenAI + Silero VAD:
55
+ pip install "pipecat-piopiy[example]"
56
+ ```
57
+
58
+ Python 3.10+. Depends on `pipecat-ai[livekit]` and `piopiy-agent`, the
59
+ framework-agnostic Piopiy worker SDK.
60
+
61
+ ## Use with a pipeline
62
+
63
+ ```python
64
+ from pipecat_piopiy import PiopiyRunner, PiopiyCallControl, piopiy_tools
65
+ from pipecat_piopiy.processors import PiopiyEventsProcessor
66
+
67
+ async def bot(transport, call):
68
+ control = PiopiyCallControl(call)
69
+ tools = piopiy_tools(control, transfer_number="919876543210",
70
+ transfer_caller_id="911203134087")
71
+
72
+ context = LLMContext(messages=[{"role": "system", "content": PROMPT}],
73
+ tools=tools.schemas)
74
+ aggregators = LLMContextAggregatorPair(
75
+ context, user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()))
76
+
77
+ pipeline = Pipeline([
78
+ transport.input(),
79
+ PiopiyEventsProcessor(call), # transfer progress -> frames + narration
80
+ stt, aggregators.user(), llm, tts,
81
+ transport.output(), aggregators.assistant(),
82
+ ])
83
+ task = PipelineTask(pipeline)
84
+
85
+ @transport.event_handler("on_first_participant_joined")
86
+ async def on_caller_joined(_t, participant_id):
87
+ await task.queue_frames([LLMRunFrame()])
88
+
89
+ @transport.event_handler("on_participant_left")
90
+ async def on_caller_left(_t, participant_id, reason):
91
+ await task.cancel()
92
+
93
+ await PipelineRunner(handle_sigint=False).run(task)
94
+
95
+ PiopiyRunner().run(bot)
96
+ ```
97
+
98
+ `PiopiyRunner` connects to Piopiy as a worker for your agent, and for every
99
+ call builds a `LiveKitTransport` pointed at the call's room and calls `bot`.
100
+ It accepts the call when the transport connects, which is when the caller is
101
+ bridged in. Use `PipelineRunner(handle_sigint=False)`: the worker owns the
102
+ process signals.
103
+
104
+ ## Run the example
105
+
106
+ ```bash
107
+ cd examples/foundational
108
+ cp .env.example .env # agent id, token, API base, transfer number, keys
109
+ python 01_piopiy_agent.py
110
+ ```
111
+
112
+ Then call your agent: place a call with `POST /v3/voice/ai/call`, ring one of
113
+ your numbers mapped to the agent, or dial the agent id from a PBX registered
114
+ over SIP Connect. Ask for a person to see a warm transfer, ask for "the billing
115
+ line" to see a blind transfer, and say goodbye to see it hang up.
116
+
117
+ ## Configuration
118
+
119
+ | variable | what |
120
+ |---|---|
121
+ | `PIOPIY_AGENT_ID` | the agent this worker serves, from the dashboard |
122
+ | `PIOPIY_TOKEN` | the Bearer token, the same one that creates calls |
123
+ | `PIOPIY_API_URL` | the platform's `/v3` base URL, as given in your account |
124
+ | `PIOPIY_REGISTER` | optional; `host:port` of the worker register |
125
+ | `PIOPIY_TLS` | optional; `false` to talk to the register without TLS (development) |
126
+ | `PIOPIY_MAX_SESSIONS` | calls one process handles at once |
127
+
128
+ ## What the package gives you
129
+
130
+ **`PiopiyCall`** - who is calling whom: `call_id`, `direction`, `from_number`,
131
+ `to_number`, `agent_id`, `variables` from the create request, and
132
+ `sip_account_id` on SIP Connect calls so one agent can tell your PBXs apart.
133
+
134
+ **`PiopiyCallControl`** - actions on the live call over the Piopiy API:
135
+
136
+ ```python
137
+ result = await control.warm_transfer(to_number="9198...", transfer_summary="Refund on order A-1042")
138
+ result = await control.warm_transfer(sip_uri="sip:desk@pbx.example.com", sip_headers={"X-Ticket": "A-1042"})
139
+ result = await control.blind_transfer(to_number="9198...", caller_id="9112...")
140
+ await control.hangup(reason="resolved")
141
+ verdict = await control.wait_for_transfer(result.request_id) # queued -> completed | failed
142
+ ```
143
+
144
+ A warm transfer rings the human while the caller stays in conversation with
145
+ the agent; on answer the caller is handed over and the agent leaves; if nobody
146
+ answers the conversation simply continues. A blind transfer hands the caller
147
+ over at once. One transfer at a time per call: a second one is refused with
148
+ `PiopiyAPIError(409, "transfer_in_progress")` carrying the running transfer's
149
+ `request_id`.
150
+
151
+ **`piopiy_tools()`** - `transfer_call` and `end_call` as LLM function calls.
152
+ Each `FunctionSchema` carries its handler, so advertising `tools.schemas` on
153
+ the `LLMContext` is all the wiring. The destination is fixed in code by
154
+ default; pass `allow_model_destination=True` to let the model choose a
155
+ number, and `transfer_caller_id` for the DID to present, which SIP Connect
156
+ calls require.
157
+
158
+ **`PiopiyEventsProcessor`** - the platform pushes every transfer's progress
159
+ into the call's room. The processor turns each message into a
160
+ `PiopiyTransferStatusFrame` (`started`, `failed` with a reason, `completed`)
161
+ and, by default, speaks it: "I'm connecting you now" as the target rings, an
162
+ apology when it fails, plus a note into the LLM context so the model carries
163
+ on sensibly. Pass `narrate=False` to handle the frames yourself. `completed`
164
+ is best-effort: at that moment the agent is being removed from the call, and
165
+ `on_participant_left` fires.
166
+
167
+ ## Notes
168
+
169
+ - Every action uses the call's customer leg, which `PiopiyCall.call_id` is.
170
+ - SIP Connect calls consume no phone number, so a transfer to a phone from one
171
+ needs `transfer_caller_id` (a DID you own).
172
+ - Accept timing is handled for you: the runner accepts on `on_connected`, and
173
+ if the join missed the platform's deadline it cancels the bot so two agents
174
+ never share a call.
175
+ - Pipecat changes quickly. This release is tested against v1.8.1; the pinned
176
+ range in `pyproject.toml` is `>=1.8,<2`.
177
+
178
+ ## License
179
+
180
+ MIT. Copyright TeleCMI.
@@ -0,0 +1,147 @@
1
+ # pipecat-piopiy
2
+
3
+ Piopiy telephony for [Pipecat](https://github.com/pipecat-ai/pipecat) voice
4
+ agents. Your Pipecat bot takes real phone calls on the Piopiy platform by
5
+ TeleCMI: calls the Piopiy API places, calls arriving on your numbers, and
6
+ calls arriving from your PBX over SIP Connect. Mid-call it can transfer the
7
+ caller to a human, warm or blind, and hang up.
8
+
9
+ Piopiy bridges each call into a LiveKit room and hands your worker the room.
10
+ Media runs through Pipecat's own `LiveKitTransport`; this package supplies the
11
+ rest - taking the call, knowing who is calling, acting on the call, and hearing
12
+ about transfers.
13
+
14
+ **Tested with Pipecat v1.8.1.** Community-maintained by
15
+ [TeleCMI](https://telecmi.com); not part of the Pipecat core.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install pipecat-piopiy
21
+ # for the example, with Deepgram + OpenAI + Silero VAD:
22
+ pip install "pipecat-piopiy[example]"
23
+ ```
24
+
25
+ Python 3.10+. Depends on `pipecat-ai[livekit]` and `piopiy-agent`, the
26
+ framework-agnostic Piopiy worker SDK.
27
+
28
+ ## Use with a pipeline
29
+
30
+ ```python
31
+ from pipecat_piopiy import PiopiyRunner, PiopiyCallControl, piopiy_tools
32
+ from pipecat_piopiy.processors import PiopiyEventsProcessor
33
+
34
+ async def bot(transport, call):
35
+ control = PiopiyCallControl(call)
36
+ tools = piopiy_tools(control, transfer_number="919876543210",
37
+ transfer_caller_id="911203134087")
38
+
39
+ context = LLMContext(messages=[{"role": "system", "content": PROMPT}],
40
+ tools=tools.schemas)
41
+ aggregators = LLMContextAggregatorPair(
42
+ context, user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()))
43
+
44
+ pipeline = Pipeline([
45
+ transport.input(),
46
+ PiopiyEventsProcessor(call), # transfer progress -> frames + narration
47
+ stt, aggregators.user(), llm, tts,
48
+ transport.output(), aggregators.assistant(),
49
+ ])
50
+ task = PipelineTask(pipeline)
51
+
52
+ @transport.event_handler("on_first_participant_joined")
53
+ async def on_caller_joined(_t, participant_id):
54
+ await task.queue_frames([LLMRunFrame()])
55
+
56
+ @transport.event_handler("on_participant_left")
57
+ async def on_caller_left(_t, participant_id, reason):
58
+ await task.cancel()
59
+
60
+ await PipelineRunner(handle_sigint=False).run(task)
61
+
62
+ PiopiyRunner().run(bot)
63
+ ```
64
+
65
+ `PiopiyRunner` connects to Piopiy as a worker for your agent, and for every
66
+ call builds a `LiveKitTransport` pointed at the call's room and calls `bot`.
67
+ It accepts the call when the transport connects, which is when the caller is
68
+ bridged in. Use `PipelineRunner(handle_sigint=False)`: the worker owns the
69
+ process signals.
70
+
71
+ ## Run the example
72
+
73
+ ```bash
74
+ cd examples/foundational
75
+ cp .env.example .env # agent id, token, API base, transfer number, keys
76
+ python 01_piopiy_agent.py
77
+ ```
78
+
79
+ Then call your agent: place a call with `POST /v3/voice/ai/call`, ring one of
80
+ your numbers mapped to the agent, or dial the agent id from a PBX registered
81
+ over SIP Connect. Ask for a person to see a warm transfer, ask for "the billing
82
+ line" to see a blind transfer, and say goodbye to see it hang up.
83
+
84
+ ## Configuration
85
+
86
+ | variable | what |
87
+ |---|---|
88
+ | `PIOPIY_AGENT_ID` | the agent this worker serves, from the dashboard |
89
+ | `PIOPIY_TOKEN` | the Bearer token, the same one that creates calls |
90
+ | `PIOPIY_API_URL` | the platform's `/v3` base URL, as given in your account |
91
+ | `PIOPIY_REGISTER` | optional; `host:port` of the worker register |
92
+ | `PIOPIY_TLS` | optional; `false` to talk to the register without TLS (development) |
93
+ | `PIOPIY_MAX_SESSIONS` | calls one process handles at once |
94
+
95
+ ## What the package gives you
96
+
97
+ **`PiopiyCall`** - who is calling whom: `call_id`, `direction`, `from_number`,
98
+ `to_number`, `agent_id`, `variables` from the create request, and
99
+ `sip_account_id` on SIP Connect calls so one agent can tell your PBXs apart.
100
+
101
+ **`PiopiyCallControl`** - actions on the live call over the Piopiy API:
102
+
103
+ ```python
104
+ result = await control.warm_transfer(to_number="9198...", transfer_summary="Refund on order A-1042")
105
+ result = await control.warm_transfer(sip_uri="sip:desk@pbx.example.com", sip_headers={"X-Ticket": "A-1042"})
106
+ result = await control.blind_transfer(to_number="9198...", caller_id="9112...")
107
+ await control.hangup(reason="resolved")
108
+ verdict = await control.wait_for_transfer(result.request_id) # queued -> completed | failed
109
+ ```
110
+
111
+ A warm transfer rings the human while the caller stays in conversation with
112
+ the agent; on answer the caller is handed over and the agent leaves; if nobody
113
+ answers the conversation simply continues. A blind transfer hands the caller
114
+ over at once. One transfer at a time per call: a second one is refused with
115
+ `PiopiyAPIError(409, "transfer_in_progress")` carrying the running transfer's
116
+ `request_id`.
117
+
118
+ **`piopiy_tools()`** - `transfer_call` and `end_call` as LLM function calls.
119
+ Each `FunctionSchema` carries its handler, so advertising `tools.schemas` on
120
+ the `LLMContext` is all the wiring. The destination is fixed in code by
121
+ default; pass `allow_model_destination=True` to let the model choose a
122
+ number, and `transfer_caller_id` for the DID to present, which SIP Connect
123
+ calls require.
124
+
125
+ **`PiopiyEventsProcessor`** - the platform pushes every transfer's progress
126
+ into the call's room. The processor turns each message into a
127
+ `PiopiyTransferStatusFrame` (`started`, `failed` with a reason, `completed`)
128
+ and, by default, speaks it: "I'm connecting you now" as the target rings, an
129
+ apology when it fails, plus a note into the LLM context so the model carries
130
+ on sensibly. Pass `narrate=False` to handle the frames yourself. `completed`
131
+ is best-effort: at that moment the agent is being removed from the call, and
132
+ `on_participant_left` fires.
133
+
134
+ ## Notes
135
+
136
+ - Every action uses the call's customer leg, which `PiopiyCall.call_id` is.
137
+ - SIP Connect calls consume no phone number, so a transfer to a phone from one
138
+ needs `transfer_caller_id` (a DID you own).
139
+ - Accept timing is handled for you: the runner accepts on `on_connected`, and
140
+ if the join missed the platform's deadline it cancels the bot so two agents
141
+ never share a call.
142
+ - Pipecat changes quickly. This release is tested against v1.8.1; the pinned
143
+ range in `pyproject.toml` is `>=1.8,<2`.
144
+
145
+ ## License
146
+
147
+ MIT. Copyright TeleCMI.
@@ -0,0 +1,44 @@
1
+ """Piopiy (TeleCMI) telephony for Pipecat voice agents.
2
+
3
+ from pipecat_piopiy import PiopiyRunner, PiopiyCallControl, piopiy_tools
4
+ from pipecat_piopiy.processors import PiopiyEventsProcessor
5
+
6
+ async def bot(transport, call):
7
+ control = PiopiyCallControl(call)
8
+ tools = piopiy_tools(control, transfer_number="919876543210")
9
+ ...
10
+
11
+ PiopiyRunner().run(bot)
12
+ """
13
+
14
+ from .call import PiopiyCall
15
+ from .control import (
16
+ TRANSFER_FAILURE_REASONS,
17
+ PiopiyAPIError,
18
+ PiopiyCallControl,
19
+ TransferResult,
20
+ TransferStatus,
21
+ build_connect_pipeline,
22
+ )
23
+ from .frames import PiopiyTransferStatusFrame
24
+ from .processors import PiopiyEventsProcessor
25
+ from .runner import PiopiyRunner
26
+ from .tools import PiopiyTools, piopiy_tools
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "TRANSFER_FAILURE_REASONS",
32
+ "PiopiyAPIError",
33
+ "PiopiyCall",
34
+ "PiopiyCallControl",
35
+ "PiopiyEventsProcessor",
36
+ "PiopiyRunner",
37
+ "PiopiyTools",
38
+ "PiopiyTransferStatusFrame",
39
+ "TransferResult",
40
+ "TransferStatus",
41
+ "__version__",
42
+ "build_connect_pipeline",
43
+ "piopiy_tools",
44
+ ]
@@ -0,0 +1,92 @@
1
+ """The call a Piopiy worker was handed: who is calling whom, and how to act on it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class PiopiyCall:
11
+ """One phone call, as offered to your agent.
12
+
13
+ Built by :class:`~pipecat_piopiy.runner.PiopiyRunner` from the job the
14
+ Piopiy platform dispatched. Every live-call action (transfer, hangup,
15
+ status) keys on ``call_id``, which is the customer leg of the call - the
16
+ id the platform expects on ``/v3/calls/{call_id}/actions/*``.
17
+
18
+ Attributes:
19
+ call_id: The customer leg's call id. Use it for every action.
20
+ room_name: The LiveKit room the call's media is bridged into.
21
+ direction: ``"inbound"`` when someone called the agent, ``"outbound"``
22
+ when the agent called them.
23
+ from_number: The caller's number. On SIP Connect calls this is the
24
+ PBX's SIP user, not a phone number.
25
+ to_number: The number dialled. On SIP Connect calls this is the agent
26
+ id or extension the PBX dialled.
27
+ agent_id: The Piopiy agent handling this call.
28
+ variables: Key/values passed on call creation as ``variables`` - a
29
+ campaign's context for the conversation. Empty on inbound calls
30
+ unless the platform attached any.
31
+ sip_headers: SIP headers the platform forwarded, when any.
32
+ sip_account_id: On SIP Connect calls, the SIP account the call came in
33
+ on, so one agent can tell PBXs or sites apart. ``None`` on calls
34
+ that arrived over a phone number.
35
+ trace_id: The platform's trace id for this call. Quote it to support.
36
+ """
37
+
38
+ call_id: str
39
+ room_name: str
40
+ direction: str
41
+ from_number: str
42
+ to_number: str
43
+ agent_id: str
44
+ variables: dict[str, str] = field(default_factory=dict)
45
+ sip_headers: dict[str, str] = field(default_factory=dict)
46
+ sip_account_id: str | None = None
47
+ trace_id: str = ""
48
+
49
+ # Processors that want the platform's room messages register here; the
50
+ # runner fans each message out to them. Not part of the public surface.
51
+ _listeners: list[Any] = field(default_factory=list, repr=False, compare=False)
52
+
53
+ @property
54
+ def is_inbound(self) -> bool:
55
+ """True when the person on the line called us."""
56
+ return self.direction == "inbound"
57
+
58
+ @property
59
+ def is_sip_connect(self) -> bool:
60
+ """True when the call arrived over SIP Connect rather than a phone number."""
61
+ return self.sip_account_id is not None
62
+
63
+ @classmethod
64
+ def from_job(cls, job: Any, *, agent_id: str) -> PiopiyCall:
65
+ """Build a call from a ``piopiy_agent.Job``.
66
+
67
+ Args:
68
+ job: The job the worker received.
69
+ agent_id: The agent id this worker serves.
70
+
71
+ Returns:
72
+ The call, ready to hand to your bot.
73
+ """
74
+ headers = dict(getattr(job.call, "sip_headers", {}) or {})
75
+ variables = dict(getattr(job.call, "variables", {}) or {})
76
+ sip_account_id = (
77
+ headers.get("X-SIP-Account-ID")
78
+ or headers.get("x-sip-account-id")
79
+ or variables.get("sip_account_id")
80
+ )
81
+ return cls(
82
+ call_id=job.call.call_uuid,
83
+ room_name=job.room_name,
84
+ direction=job.call.direction or "inbound",
85
+ from_number=job.call.from_number,
86
+ to_number=job.call.to_number,
87
+ agent_id=agent_id,
88
+ variables=variables,
89
+ sip_headers=headers,
90
+ sip_account_id=sip_account_id,
91
+ trace_id=getattr(job, "trace_id", "") or "",
92
+ )