livekit-msteams-bridge 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.
- livekit_msteams_bridge-0.1.0/.env.example +35 -0
- livekit_msteams_bridge-0.1.0/.gitignore +11 -0
- livekit_msteams_bridge-0.1.0/LICENSE +21 -0
- livekit_msteams_bridge-0.1.0/PKG-INFO +168 -0
- livekit_msteams_bridge-0.1.0/README.md +135 -0
- livekit_msteams_bridge-0.1.0/examples/basic-bridge/.env.example +35 -0
- livekit_msteams_bridge-0.1.0/examples/basic-bridge/README.md +20 -0
- livekit_msteams_bridge-0.1.0/pyproject.toml +64 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/__init__.py +58 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/cli.py +85 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/config.py +119 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/hmac_auth.py +37 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/livekit_room.py +293 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/log.py +52 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/metrics.py +46 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/protocol.py +32 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/py.typed +0 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/server.py +392 -0
- livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/session.py +473 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/agents-and-dispatch.md +89 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/architecture.md +69 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/configuration-reference.md +58 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/connecting-to-standin.md +77 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/contributing.md +31 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/example.md +82 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/getting-started.md +100 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/governors-and-privacy.md +35 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/index.mdx +38 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/library-api.md +82 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/troubleshooting.md +49 -0
- livekit_msteams_bridge-0.1.0/website/src/content/docs/wire-protocol.md +67 -0
- livekit_msteams_bridge-0.1.0/website/src/content.config.ts +7 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# --- required ---
|
|
2
|
+
WORKER_SHARED_SECRET=shared-secret-from-standin-pairing
|
|
3
|
+
LIVEKIT_URL=wss://your-project.livekit.cloud
|
|
4
|
+
LIVEKIT_API_KEY=API...
|
|
5
|
+
LIVEKIT_API_SECRET=...
|
|
6
|
+
|
|
7
|
+
# --- agent dispatch ---
|
|
8
|
+
# Named agent for explicit dispatch (recommended). Must equal the agent_name
|
|
9
|
+
# your worker registers with. Unset = automatic dispatch (prototype-only).
|
|
10
|
+
LIVEKIT_AGENT_NAME=teams-voice-agent
|
|
11
|
+
#LIVEKIT_ROOM_PREFIX=msteams-
|
|
12
|
+
#LIVEKIT_DELETE_ROOM_ON_END=true
|
|
13
|
+
|
|
14
|
+
# --- listener ---
|
|
15
|
+
#PORT=8080
|
|
16
|
+
#BIND=0.0.0.0
|
|
17
|
+
# Native TLS (wss). Leave unset when a tunnel/ingress terminates TLS for you.
|
|
18
|
+
#TLS_CERT_PATH=/path/cert.pem
|
|
19
|
+
#TLS_KEY_PATH=/path/key.pem
|
|
20
|
+
|
|
21
|
+
# --- call governor ---
|
|
22
|
+
#MAX_CALL_MINUTES=10
|
|
23
|
+
#GOODBYE_TEXT=I'm sorry, we've reached the time limit for this call. Thank you for calling, goodbye!
|
|
24
|
+
#GOODBYE_GRACE_MS=8000
|
|
25
|
+
|
|
26
|
+
# --- hardening ---
|
|
27
|
+
#HMAC_FRESHNESS_MS=60000
|
|
28
|
+
#MAX_CONNECTIONS=64
|
|
29
|
+
#MAX_CONNECTIONS_PER_IP=
|
|
30
|
+
#PRE_START_TIMEOUT_MS=10000
|
|
31
|
+
#WORKER_IDLE_TIMEOUT_MS=90000
|
|
32
|
+
#TRUST_PROXY_XFF=false
|
|
33
|
+
|
|
34
|
+
# --- logging ---
|
|
35
|
+
#LOG_LEVEL=info
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alaaeldin Elhenawy
|
|
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,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livekit-msteams-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Put a LiveKit Agent on Microsoft Teams voice/video calls. Terminates the StandIn media bridge wire protocol on one side and a LiveKit room on the other: per-call rooms, explicit agent dispatch, 16 kHz PCM relay, data-topic context, call governors.
|
|
5
|
+
Project-URL: Homepage, https://komaa-com.github.io/livekit-msteams-bridge-py/
|
|
6
|
+
Project-URL: Documentation, https://komaa-com.github.io/livekit-msteams-bridge-py/
|
|
7
|
+
Project-URL: StandIn docs, https://docs.komaa.com/livekit/installation
|
|
8
|
+
Project-URL: Repository, https://github.com/komaa-com/livekit-msteams-bridge-py
|
|
9
|
+
Project-URL: Issues, https://github.com/komaa-com/livekit-msteams-bridge-py/issues
|
|
10
|
+
Author-email: Alaaeldin Elhenawy <alaamh@outlook.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agent,avatar,bridge,livekit,microsoft-teams,telephony,voice,websocket
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Communications :: Telephony
|
|
23
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: aiohttp<4,>=3.9
|
|
26
|
+
Requires-Dist: livekit-api<2,>=1.2
|
|
27
|
+
Requires-Dist: livekit<2,>=1.1
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# livekit-msteams-bridge (Python)
|
|
35
|
+
|
|
36
|
+
Put a **LiveKit Agent** - including avatar agents - on **Microsoft Teams voice/video calls**.
|
|
37
|
+
|
|
38
|
+
> PyPI package: **`livekit-msteams-bridge`** - the `-py` suffix is only in this repository's
|
|
39
|
+
> name, to distinguish it from the [Node.js sibling repo](https://github.com/komaa-com/livekit-msteams-bridge).
|
|
40
|
+
|
|
41
|
+
This is the Python sibling of [`@komaa/livekit-msteams-bridge`](https://www.npmjs.com/package/@komaa/livekit-msteams-bridge)
|
|
42
|
+
(Node.js) - same wire contract, same environment variables, drop-in interchangeable behind the same
|
|
43
|
+
`.env` file. It terminates the StandIn media bridge wire protocol on one side and a LiveKit room on
|
|
44
|
+
the other:
|
|
45
|
+
|
|
46
|
+
- **One room per call**: the bridge creates a fresh room, joins as a participant, and dispatches
|
|
47
|
+
your agent into it (explicit dispatch by `agent_name` - LiveKit's recommended model).
|
|
48
|
+
- **No transcoding on our side**: the worker speaks 16 kHz mono PCM16 natively; the room side uses
|
|
49
|
+
the SDK's resampling `AudioSource`/`AudioStream`, so the bridge itself never transcodes.
|
|
50
|
+
- **Agent integration without Teams code**: per-call metadata (`ctx.job.metadata` - caller name,
|
|
51
|
+
tenant, direction, AAD id when known) plus two data topics: `teams.context` (participants, DTMF,
|
|
52
|
+
recording state) and `teams.goodbye` (the governor's goodbye line to speak).
|
|
53
|
+
- **Call governors**: a bridge-side hard time cap (the agent speaks the goodbye), plus the
|
|
54
|
+
StandIn-side governor.
|
|
55
|
+
- **Hardened**: HMAC-signed upgrades with replay guard, connection caps, dead-peer detection,
|
|
56
|
+
graceful SIGTERM drain, Prometheus `/metrics`.
|
|
57
|
+
|
|
58
|
+
[StandIn](https://standin.komaa.com) is the hosted media bridge that joins the Teams call and dials
|
|
59
|
+
this bridge - you run no Teams media stack yourself.
|
|
60
|
+
|
|
61
|
+
**Documentation**: [komaa-com.github.io/livekit-msteams-bridge-py](https://komaa-com.github.io/livekit-msteams-bridge-py/)
|
|
62
|
+
(getting started, example walkthrough, agents and dispatch, configuration and library reference,
|
|
63
|
+
wire protocol). Teams/StandIn setup lives at [docs.komaa.com](https://docs.komaa.com/livekit/installation).
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install livekit-msteams-bridge
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Requires Python 3.10+.
|
|
72
|
+
|
|
73
|
+
## Run
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
LIVEKIT_URL=wss://your-project.livekit.cloud \
|
|
77
|
+
LIVEKIT_API_KEY=API... \
|
|
78
|
+
LIVEKIT_API_SECRET=... \
|
|
79
|
+
LIVEKIT_AGENT_NAME=teams-voice-agent \
|
|
80
|
+
WORKER_SHARED_SECRET=... \
|
|
81
|
+
livekit-msteams-bridge
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A `.env` file in the working directory is loaded automatically (existing environment wins). The
|
|
85
|
+
bridge listens on `ws://0.0.0.0:8080/voice/msteams/stream` by default; StandIn appends `/{callId}`
|
|
86
|
+
per call. Expose the port with a tunnel and register the `wss://` URL as your identity's
|
|
87
|
+
**Agent voice URL** in the StandIn dashboard.
|
|
88
|
+
|
|
89
|
+
`LIVEKIT_AGENT_NAME` must equal the `agent_name` your worker registers with
|
|
90
|
+
(`WorkerOptions(entrypoint_fnc=..., agent_name="teams-voice-agent")`). A mismatch is the classic
|
|
91
|
+
silent failure: the room is created, the caller hears nothing, and the worker never gets a job.
|
|
92
|
+
|
|
93
|
+
## Embed
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import asyncio
|
|
97
|
+
from livekit_msteams_bridge import load_config, start_server
|
|
98
|
+
|
|
99
|
+
async def main():
|
|
100
|
+
server = await start_server(load_config())
|
|
101
|
+
await asyncio.Event().wait() # run until cancelled
|
|
102
|
+
|
|
103
|
+
asyncio.run(main())
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Tests can inject a fake room with the `connect_room` argument - see the test suite's
|
|
107
|
+
`FakeRoomPort` for the shape.
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
Everything is environment variables; names are identical to the Node package.
|
|
112
|
+
|
|
113
|
+
| Variable | Required | Default | Meaning |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `WORKER_SHARED_SECRET` | yes | - | Must equal the shared secret from StandIn pairing (HMAC upgrade check). |
|
|
116
|
+
| `LIVEKIT_URL` | yes | - | LiveKit server URL (`wss://<project>.livekit.cloud` or self-hosted). |
|
|
117
|
+
| `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | yes | - | Mint join tokens, dispatch agents, delete rooms. Server-side only. |
|
|
118
|
+
| `LIVEKIT_AGENT_NAME` | no | - | Named agent for explicit dispatch (recommended). Unset = automatic dispatch (prototype-only). |
|
|
119
|
+
| `LIVEKIT_ROOM_PREFIX` | no | `msteams-` | Room name prefix; the room is `{prefix}{callId}` (sanitized). |
|
|
120
|
+
| `LIVEKIT_DELETE_ROOM_ON_END` | no | `true` | Delete the room at teardown so the agent job ends immediately (billing hygiene). |
|
|
121
|
+
| `MAX_CALL_MINUTES` | no | `0` (off) | Bridge-side hard cap per call; on expiry the agent is asked to say goodbye, then the call ends. |
|
|
122
|
+
| `GOODBYE_TEXT` / `GOODBYE_GRACE_MS` | no | (default line) / `8000` | Goodbye wording (sent on `teams.goodbye`) and playout grace. The call ends `GOODBYE_GRACE_MS` + a fixed 500 ms scheduling buffer after the goodbye request. |
|
|
123
|
+
| `PORT` / `BIND` | no | `8080` / `0.0.0.0` | Listen port / bind address. |
|
|
124
|
+
| `HMAC_FRESHNESS_MS` | no | `60000` | Two-sided freshness window: a timestamp up to 60 s in the past OR the future is accepted, and the replay guard holds a used handshake until the timestamp ages out. |
|
|
125
|
+
| `MAX_CONNECTIONS` / `MAX_CONNECTIONS_PER_IP` | no | `64` / = total | Connection caps. |
|
|
126
|
+
| `PRE_START_TIMEOUT_MS` | no | `10000` | Drop a worker that authenticates but never sends `session.start`. |
|
|
127
|
+
| `WORKER_IDLE_TIMEOUT_MS` | no | `90000` | Dead-peer window (the worker heartbeats every 30 s). |
|
|
128
|
+
| `TRUST_PROXY_XFF` | no | `false` | Trust the first `X-Forwarded-For` hop for the per-IP cap. |
|
|
129
|
+
| `TLS_CERT_PATH` / `TLS_KEY_PATH` | no | - | Serve native TLS (`wss`). Otherwise front the plain WS with a TLS terminator. |
|
|
130
|
+
| `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. |
|
|
131
|
+
|
|
132
|
+
## Endpoints
|
|
133
|
+
|
|
134
|
+
- `GET /healthz` - liveness.
|
|
135
|
+
- `GET /metrics` - Prometheus counters (calls, rejections, relayed/dropped frames).
|
|
136
|
+
- `GET /{...}/{callId}` + WebSocket upgrade - the worker wire, HMAC-signed with
|
|
137
|
+
`X-OpenClawTeamsBridge-Timestamp` / `X-OpenClawTeamsBridge-Signature` over
|
|
138
|
+
`"{timestampMs}.{callId}"`.
|
|
139
|
+
|
|
140
|
+
Notes for operators:
|
|
141
|
+
|
|
142
|
+
- `/healthz` and `/metrics` are **unauthenticated** (only the WebSocket upgrade is HMAC-gated).
|
|
143
|
+
They expose no call content - just liveness and counters - but if you would rather not leak call
|
|
144
|
+
volumes, keep the port behind your ingress/tunnel rules.
|
|
145
|
+
- Barge-in: interruption handling lives **inside your LiveKit agent session** (VAD, turn-taking),
|
|
146
|
+
exactly as for WebRTC callers; the room transport gives the bridge no interruption event to relay,
|
|
147
|
+
so the worker's own flush-on-silence smooths the tail end.
|
|
148
|
+
- The bridge participant's join token has a fixed **6 h TTL**. Calls that should be allowed to run
|
|
149
|
+
longer than that need a re-join strategy; in practice set `MAX_CALL_MINUTES` well below it.
|
|
150
|
+
- Inbound Teams **video** (`video.frame`) is not forwarded to the room in this version - the bot's
|
|
151
|
+
tile is rendered by the worker's own avatar. Publishing caller video as a room track is on the
|
|
152
|
+
roadmap.
|
|
153
|
+
- The Docker image exposes port **8080** and does not remap `PORT`/`BIND` at the Docker layer; use
|
|
154
|
+
`-e PORT=... -p <host>:<port>` together if you change them.
|
|
155
|
+
|
|
156
|
+
## Agent integration points
|
|
157
|
+
|
|
158
|
+
Your agent needs no Teams-specific code, but three integration points are available:
|
|
159
|
+
|
|
160
|
+
- **`agent_name`** in `WorkerOptions` - must match `LIVEKIT_AGENT_NAME` for explicit dispatch.
|
|
161
|
+
- **`ctx.job.metadata`** (JSON) - per-call context: `source`, `caller_name`, `tenant_id`,
|
|
162
|
+
`call_direction`, and `user_id` (AAD id when Teams provides one).
|
|
163
|
+
- **Data topics** - `teams.context` (participant count, DTMF, recording state) and `teams.goodbye`
|
|
164
|
+
(the governor's goodbye line; have your handler speak it and interrupt the current turn).
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT (c) Alaaeldin Elhenawy
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# livekit-msteams-bridge (Python)
|
|
2
|
+
|
|
3
|
+
Put a **LiveKit Agent** - including avatar agents - on **Microsoft Teams voice/video calls**.
|
|
4
|
+
|
|
5
|
+
> PyPI package: **`livekit-msteams-bridge`** - the `-py` suffix is only in this repository's
|
|
6
|
+
> name, to distinguish it from the [Node.js sibling repo](https://github.com/komaa-com/livekit-msteams-bridge).
|
|
7
|
+
|
|
8
|
+
This is the Python sibling of [`@komaa/livekit-msteams-bridge`](https://www.npmjs.com/package/@komaa/livekit-msteams-bridge)
|
|
9
|
+
(Node.js) - same wire contract, same environment variables, drop-in interchangeable behind the same
|
|
10
|
+
`.env` file. It terminates the StandIn media bridge wire protocol on one side and a LiveKit room on
|
|
11
|
+
the other:
|
|
12
|
+
|
|
13
|
+
- **One room per call**: the bridge creates a fresh room, joins as a participant, and dispatches
|
|
14
|
+
your agent into it (explicit dispatch by `agent_name` - LiveKit's recommended model).
|
|
15
|
+
- **No transcoding on our side**: the worker speaks 16 kHz mono PCM16 natively; the room side uses
|
|
16
|
+
the SDK's resampling `AudioSource`/`AudioStream`, so the bridge itself never transcodes.
|
|
17
|
+
- **Agent integration without Teams code**: per-call metadata (`ctx.job.metadata` - caller name,
|
|
18
|
+
tenant, direction, AAD id when known) plus two data topics: `teams.context` (participants, DTMF,
|
|
19
|
+
recording state) and `teams.goodbye` (the governor's goodbye line to speak).
|
|
20
|
+
- **Call governors**: a bridge-side hard time cap (the agent speaks the goodbye), plus the
|
|
21
|
+
StandIn-side governor.
|
|
22
|
+
- **Hardened**: HMAC-signed upgrades with replay guard, connection caps, dead-peer detection,
|
|
23
|
+
graceful SIGTERM drain, Prometheus `/metrics`.
|
|
24
|
+
|
|
25
|
+
[StandIn](https://standin.komaa.com) is the hosted media bridge that joins the Teams call and dials
|
|
26
|
+
this bridge - you run no Teams media stack yourself.
|
|
27
|
+
|
|
28
|
+
**Documentation**: [komaa-com.github.io/livekit-msteams-bridge-py](https://komaa-com.github.io/livekit-msteams-bridge-py/)
|
|
29
|
+
(getting started, example walkthrough, agents and dispatch, configuration and library reference,
|
|
30
|
+
wire protocol). Teams/StandIn setup lives at [docs.komaa.com](https://docs.komaa.com/livekit/installation).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install livekit-msteams-bridge
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+.
|
|
39
|
+
|
|
40
|
+
## Run
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
LIVEKIT_URL=wss://your-project.livekit.cloud \
|
|
44
|
+
LIVEKIT_API_KEY=API... \
|
|
45
|
+
LIVEKIT_API_SECRET=... \
|
|
46
|
+
LIVEKIT_AGENT_NAME=teams-voice-agent \
|
|
47
|
+
WORKER_SHARED_SECRET=... \
|
|
48
|
+
livekit-msteams-bridge
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A `.env` file in the working directory is loaded automatically (existing environment wins). The
|
|
52
|
+
bridge listens on `ws://0.0.0.0:8080/voice/msteams/stream` by default; StandIn appends `/{callId}`
|
|
53
|
+
per call. Expose the port with a tunnel and register the `wss://` URL as your identity's
|
|
54
|
+
**Agent voice URL** in the StandIn dashboard.
|
|
55
|
+
|
|
56
|
+
`LIVEKIT_AGENT_NAME` must equal the `agent_name` your worker registers with
|
|
57
|
+
(`WorkerOptions(entrypoint_fnc=..., agent_name="teams-voice-agent")`). A mismatch is the classic
|
|
58
|
+
silent failure: the room is created, the caller hears nothing, and the worker never gets a job.
|
|
59
|
+
|
|
60
|
+
## Embed
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from livekit_msteams_bridge import load_config, start_server
|
|
65
|
+
|
|
66
|
+
async def main():
|
|
67
|
+
server = await start_server(load_config())
|
|
68
|
+
await asyncio.Event().wait() # run until cancelled
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Tests can inject a fake room with the `connect_room` argument - see the test suite's
|
|
74
|
+
`FakeRoomPort` for the shape.
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
Everything is environment variables; names are identical to the Node package.
|
|
79
|
+
|
|
80
|
+
| Variable | Required | Default | Meaning |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| `WORKER_SHARED_SECRET` | yes | - | Must equal the shared secret from StandIn pairing (HMAC upgrade check). |
|
|
83
|
+
| `LIVEKIT_URL` | yes | - | LiveKit server URL (`wss://<project>.livekit.cloud` or self-hosted). |
|
|
84
|
+
| `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | yes | - | Mint join tokens, dispatch agents, delete rooms. Server-side only. |
|
|
85
|
+
| `LIVEKIT_AGENT_NAME` | no | - | Named agent for explicit dispatch (recommended). Unset = automatic dispatch (prototype-only). |
|
|
86
|
+
| `LIVEKIT_ROOM_PREFIX` | no | `msteams-` | Room name prefix; the room is `{prefix}{callId}` (sanitized). |
|
|
87
|
+
| `LIVEKIT_DELETE_ROOM_ON_END` | no | `true` | Delete the room at teardown so the agent job ends immediately (billing hygiene). |
|
|
88
|
+
| `MAX_CALL_MINUTES` | no | `0` (off) | Bridge-side hard cap per call; on expiry the agent is asked to say goodbye, then the call ends. |
|
|
89
|
+
| `GOODBYE_TEXT` / `GOODBYE_GRACE_MS` | no | (default line) / `8000` | Goodbye wording (sent on `teams.goodbye`) and playout grace. The call ends `GOODBYE_GRACE_MS` + a fixed 500 ms scheduling buffer after the goodbye request. |
|
|
90
|
+
| `PORT` / `BIND` | no | `8080` / `0.0.0.0` | Listen port / bind address. |
|
|
91
|
+
| `HMAC_FRESHNESS_MS` | no | `60000` | Two-sided freshness window: a timestamp up to 60 s in the past OR the future is accepted, and the replay guard holds a used handshake until the timestamp ages out. |
|
|
92
|
+
| `MAX_CONNECTIONS` / `MAX_CONNECTIONS_PER_IP` | no | `64` / = total | Connection caps. |
|
|
93
|
+
| `PRE_START_TIMEOUT_MS` | no | `10000` | Drop a worker that authenticates but never sends `session.start`. |
|
|
94
|
+
| `WORKER_IDLE_TIMEOUT_MS` | no | `90000` | Dead-peer window (the worker heartbeats every 30 s). |
|
|
95
|
+
| `TRUST_PROXY_XFF` | no | `false` | Trust the first `X-Forwarded-For` hop for the per-IP cap. |
|
|
96
|
+
| `TLS_CERT_PATH` / `TLS_KEY_PATH` | no | - | Serve native TLS (`wss`). Otherwise front the plain WS with a TLS terminator. |
|
|
97
|
+
| `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. |
|
|
98
|
+
|
|
99
|
+
## Endpoints
|
|
100
|
+
|
|
101
|
+
- `GET /healthz` - liveness.
|
|
102
|
+
- `GET /metrics` - Prometheus counters (calls, rejections, relayed/dropped frames).
|
|
103
|
+
- `GET /{...}/{callId}` + WebSocket upgrade - the worker wire, HMAC-signed with
|
|
104
|
+
`X-OpenClawTeamsBridge-Timestamp` / `X-OpenClawTeamsBridge-Signature` over
|
|
105
|
+
`"{timestampMs}.{callId}"`.
|
|
106
|
+
|
|
107
|
+
Notes for operators:
|
|
108
|
+
|
|
109
|
+
- `/healthz` and `/metrics` are **unauthenticated** (only the WebSocket upgrade is HMAC-gated).
|
|
110
|
+
They expose no call content - just liveness and counters - but if you would rather not leak call
|
|
111
|
+
volumes, keep the port behind your ingress/tunnel rules.
|
|
112
|
+
- Barge-in: interruption handling lives **inside your LiveKit agent session** (VAD, turn-taking),
|
|
113
|
+
exactly as for WebRTC callers; the room transport gives the bridge no interruption event to relay,
|
|
114
|
+
so the worker's own flush-on-silence smooths the tail end.
|
|
115
|
+
- The bridge participant's join token has a fixed **6 h TTL**. Calls that should be allowed to run
|
|
116
|
+
longer than that need a re-join strategy; in practice set `MAX_CALL_MINUTES` well below it.
|
|
117
|
+
- Inbound Teams **video** (`video.frame`) is not forwarded to the room in this version - the bot's
|
|
118
|
+
tile is rendered by the worker's own avatar. Publishing caller video as a room track is on the
|
|
119
|
+
roadmap.
|
|
120
|
+
- The Docker image exposes port **8080** and does not remap `PORT`/`BIND` at the Docker layer; use
|
|
121
|
+
`-e PORT=... -p <host>:<port>` together if you change them.
|
|
122
|
+
|
|
123
|
+
## Agent integration points
|
|
124
|
+
|
|
125
|
+
Your agent needs no Teams-specific code, but three integration points are available:
|
|
126
|
+
|
|
127
|
+
- **`agent_name`** in `WorkerOptions` - must match `LIVEKIT_AGENT_NAME` for explicit dispatch.
|
|
128
|
+
- **`ctx.job.metadata`** (JSON) - per-call context: `source`, `caller_name`, `tenant_id`,
|
|
129
|
+
`call_direction`, and `user_id` (AAD id when Teams provides one).
|
|
130
|
+
- **Data topics** - `teams.context` (participant count, DTMF, recording state) and `teams.goodbye`
|
|
131
|
+
(the governor's goodbye line; have your handler speak it and interrupt the current turn).
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT (c) Alaaeldin Elhenawy
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# --- required ---
|
|
2
|
+
WORKER_SHARED_SECRET=shared-secret-from-standin-pairing
|
|
3
|
+
LIVEKIT_URL=wss://your-project.livekit.cloud
|
|
4
|
+
LIVEKIT_API_KEY=API...
|
|
5
|
+
LIVEKIT_API_SECRET=...
|
|
6
|
+
|
|
7
|
+
# --- agent dispatch ---
|
|
8
|
+
# Named agent for explicit dispatch (recommended). Must equal the agent_name
|
|
9
|
+
# your worker registers with. Unset = automatic dispatch (prototype-only).
|
|
10
|
+
LIVEKIT_AGENT_NAME=teams-voice-agent
|
|
11
|
+
#LIVEKIT_ROOM_PREFIX=msteams-
|
|
12
|
+
#LIVEKIT_DELETE_ROOM_ON_END=true
|
|
13
|
+
|
|
14
|
+
# --- listener ---
|
|
15
|
+
#PORT=8080
|
|
16
|
+
#BIND=0.0.0.0
|
|
17
|
+
# Native TLS (wss). Leave unset when a tunnel/ingress terminates TLS for you.
|
|
18
|
+
#TLS_CERT_PATH=/path/cert.pem
|
|
19
|
+
#TLS_KEY_PATH=/path/key.pem
|
|
20
|
+
|
|
21
|
+
# --- call governor ---
|
|
22
|
+
#MAX_CALL_MINUTES=10
|
|
23
|
+
#GOODBYE_TEXT=I'm sorry, we've reached the time limit for this call. Thank you for calling, goodbye!
|
|
24
|
+
#GOODBYE_GRACE_MS=8000
|
|
25
|
+
|
|
26
|
+
# --- hardening ---
|
|
27
|
+
#HMAC_FRESHNESS_MS=60000
|
|
28
|
+
#MAX_CONNECTIONS=64
|
|
29
|
+
#MAX_CONNECTIONS_PER_IP=
|
|
30
|
+
#PRE_START_TIMEOUT_MS=10000
|
|
31
|
+
#WORKER_IDLE_TIMEOUT_MS=90000
|
|
32
|
+
#TRUST_PROXY_XFF=false
|
|
33
|
+
|
|
34
|
+
# --- logging ---
|
|
35
|
+
#LOG_LEVEL=info
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# basic-bridge (Python)
|
|
2
|
+
|
|
3
|
+
Minimal, working embedding of `livekit-msteams-bridge`: `load_config()` + `start_server()`.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install livekit-msteams-bridge
|
|
9
|
+
cp .env.example .env # fill in LIVEKIT_URL/KEY/SECRET, LIVEKIT_AGENT_NAME, WORKER_SHARED_SECRET
|
|
10
|
+
python main.py
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A LiveKit call needs **two** processes: your agent runs as a worker (registered under the same
|
|
14
|
+
`agent_name` as `LIVEKIT_AGENT_NAME`), and this bridge dispatches it into a per-call room.
|
|
15
|
+
|
|
16
|
+
It prints the WebSocket URL to give StandIn. Expose port 8080 with a tunnel (Tailscale Funnel,
|
|
17
|
+
cloudflared, ngrok, ...), set your StandIn identity's **Agent voice URL** to the `wss://` URL, and
|
|
18
|
+
place a Teams call - the bridge creates a room, dispatches your agent, and the agent answers.
|
|
19
|
+
|
|
20
|
+
Full setup walkthrough (including ready-to-run example agents): https://docs.komaa.com/livekit/example
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livekit-msteams-bridge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Put a LiveKit Agent on Microsoft Teams voice/video calls. Terminates the StandIn media bridge wire protocol on one side and a LiveKit room on the other: per-call rooms, explicit agent dispatch, 16 kHz PCM relay, data-topic context, call governors."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Alaaeldin Elhenawy", email = "alaamh@outlook.com" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
# livekit pins: tested against livekit 1.1.x / livekit-api 1.2.x; the rtc API
|
|
14
|
+
# surface is verified by tests/test_livekit_sdk_surface.py on every CI run.
|
|
15
|
+
dependencies = ["aiohttp>=3.9,<4", "livekit>=1.1,<2", "livekit-api>=1.2,<2"]
|
|
16
|
+
keywords = [
|
|
17
|
+
"livekit",
|
|
18
|
+
"microsoft-teams",
|
|
19
|
+
"voice",
|
|
20
|
+
"agent",
|
|
21
|
+
"websocket",
|
|
22
|
+
"bridge",
|
|
23
|
+
"telephony",
|
|
24
|
+
"avatar",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Communications :: Telephony",
|
|
35
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
36
|
+
"Framework :: AsyncIO",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://komaa-com.github.io/livekit-msteams-bridge-py/"
|
|
41
|
+
Documentation = "https://komaa-com.github.io/livekit-msteams-bridge-py/"
|
|
42
|
+
"StandIn docs" = "https://docs.komaa.com/livekit/installation"
|
|
43
|
+
Repository = "https://github.com/komaa-com/livekit-msteams-bridge-py"
|
|
44
|
+
Issues = "https://github.com/komaa-com/livekit-msteams-bridge-py/issues"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
livekit-msteams-bridge = "livekit_msteams_bridge.cli:main"
|
|
48
|
+
|
|
49
|
+
[project.optional-dependencies]
|
|
50
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.4"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = ["src", "README.md", "LICENSE", ".env.example"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/livekit_msteams_bridge"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
asyncio_mode = "auto"
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
line-length = 120
|
|
64
|
+
target-version = "py310"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""livekit-msteams-bridge - public API.
|
|
2
|
+
|
|
3
|
+
Typical embedding:
|
|
4
|
+
|
|
5
|
+
from livekit_msteams_bridge import load_config, start_server
|
|
6
|
+
|
|
7
|
+
server = await start_server(load_config())
|
|
8
|
+
|
|
9
|
+
Or run the CLI: `livekit-msteams-bridge` (env-configured, see .env.example).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .cli import load_dotenv
|
|
13
|
+
from .config import BridgeConfig, load_config
|
|
14
|
+
from .hmac_auth import SIGNATURE_HEADER, TIMESTAMP_HEADER, is_fresh, sign, verify
|
|
15
|
+
from .log import Logger, logger
|
|
16
|
+
from .metrics import render_metrics, reset_metrics
|
|
17
|
+
from .protocol import parse_worker_message, pcm16k_bytes_to_ms
|
|
18
|
+
from .server import BridgeServer, ReplayGuard, authorize_upgrade, call_id_from_path, start_server
|
|
19
|
+
from .session import AgentRoomPort, CallSession, RoomConnector, RoomHandlers, WorkerPort
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AgentRoomPort",
|
|
25
|
+
"BridgeConfig",
|
|
26
|
+
"BridgeServer",
|
|
27
|
+
"CallSession",
|
|
28
|
+
"Logger",
|
|
29
|
+
"ReplayGuard",
|
|
30
|
+
"RoomConnector",
|
|
31
|
+
"RoomHandlers",
|
|
32
|
+
"SIGNATURE_HEADER",
|
|
33
|
+
"TIMESTAMP_HEADER",
|
|
34
|
+
"WorkerPort",
|
|
35
|
+
"__version__",
|
|
36
|
+
"authorize_upgrade",
|
|
37
|
+
"call_id_from_path",
|
|
38
|
+
"is_fresh",
|
|
39
|
+
"load_config",
|
|
40
|
+
"load_dotenv",
|
|
41
|
+
"logger",
|
|
42
|
+
"parse_worker_message",
|
|
43
|
+
"pcm16k_bytes_to_ms",
|
|
44
|
+
"render_metrics",
|
|
45
|
+
"reset_metrics",
|
|
46
|
+
"sign",
|
|
47
|
+
"start_server",
|
|
48
|
+
"verify",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
from .livekit_room import ( # noqa: E402 (after __all__ on purpose)
|
|
52
|
+
TOPIC_CONTEXT,
|
|
53
|
+
TOPIC_GOODBYE,
|
|
54
|
+
LiveKitRoomPort,
|
|
55
|
+
connect_livekit_room,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ += ["TOPIC_CONTEXT", "TOPIC_GOODBYE", "LiveKitRoomPort", "connect_livekit_room"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CLI entry point: `livekit-msteams-bridge`.
|
|
2
|
+
|
|
3
|
+
Entirely env-configured - see .env.example in the package root. A `.env` file
|
|
4
|
+
in the working directory is loaded automatically (existing environment wins).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .config import load_config
|
|
16
|
+
from .server import start_server
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_dotenv(path: str = ".env") -> None:
|
|
20
|
+
"""Tiny .env loader so the CLI matches the Node package's
|
|
21
|
+
`node --env-file=.env` convenience without a dependency. Supports
|
|
22
|
+
`KEY=VALUE`, an optional `export ` prefix (files shared with `source`),
|
|
23
|
+
quoted values, and inline ` # comments` on unquoted values. Single-line
|
|
24
|
+
values only. Existing environment variables are never overwritten."""
|
|
25
|
+
try:
|
|
26
|
+
with open(path, encoding="utf-8") as fh:
|
|
27
|
+
for line in fh:
|
|
28
|
+
line = line.strip()
|
|
29
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
30
|
+
continue
|
|
31
|
+
if line.startswith("export "):
|
|
32
|
+
line = line[len("export ") :].lstrip()
|
|
33
|
+
key, _, value = line.partition("=")
|
|
34
|
+
key = key.strip()
|
|
35
|
+
value = value.strip()
|
|
36
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
37
|
+
value = value[1:-1] # quoted: keep content verbatim (incl. '#')
|
|
38
|
+
else:
|
|
39
|
+
value = re.sub(r"\s+#.*$", "", value).strip()
|
|
40
|
+
if key and key not in os.environ:
|
|
41
|
+
os.environ[key] = value
|
|
42
|
+
except OSError:
|
|
43
|
+
pass # no .env is fine
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run() -> None:
|
|
47
|
+
cfg = load_config()
|
|
48
|
+
server = await start_server(cfg)
|
|
49
|
+
|
|
50
|
+
# SIGTERM/SIGINT drain: gracefully end every live call instead of
|
|
51
|
+
# hard-dropping calls on a redeploy.
|
|
52
|
+
loop = asyncio.get_running_loop()
|
|
53
|
+
stop = asyncio.Event()
|
|
54
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
55
|
+
try:
|
|
56
|
+
loop.add_signal_handler(sig, stop.set)
|
|
57
|
+
except NotImplementedError: # pragma: no cover - non-POSIX platforms
|
|
58
|
+
pass
|
|
59
|
+
await stop.wait()
|
|
60
|
+
await server.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> None:
|
|
64
|
+
load_dotenv()
|
|
65
|
+
try:
|
|
66
|
+
asyncio.run(_run())
|
|
67
|
+
except ValueError as err: # config errors (missing/invalid env vars)
|
|
68
|
+
print(f"livekit-msteams-bridge: {err}", file=sys.stderr)
|
|
69
|
+
print(
|
|
70
|
+
"Required env: LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, WORKER_SHARED_SECRET (see .env.example).",
|
|
71
|
+
file=sys.stderr,
|
|
72
|
+
)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
except OSError as err:
|
|
75
|
+
if getattr(err, "errno", None) == 48 or "address already in use" in str(err).lower():
|
|
76
|
+
print(f"livekit-msteams-bridge: port already in use ({err}). Set PORT to a free port.", file=sys.stderr)
|
|
77
|
+
else:
|
|
78
|
+
print(f"livekit-msteams-bridge: server error: {err}", file=sys.stderr)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|