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.
Files changed (32) hide show
  1. livekit_msteams_bridge-0.1.0/.env.example +35 -0
  2. livekit_msteams_bridge-0.1.0/.gitignore +11 -0
  3. livekit_msteams_bridge-0.1.0/LICENSE +21 -0
  4. livekit_msteams_bridge-0.1.0/PKG-INFO +168 -0
  5. livekit_msteams_bridge-0.1.0/README.md +135 -0
  6. livekit_msteams_bridge-0.1.0/examples/basic-bridge/.env.example +35 -0
  7. livekit_msteams_bridge-0.1.0/examples/basic-bridge/README.md +20 -0
  8. livekit_msteams_bridge-0.1.0/pyproject.toml +64 -0
  9. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/__init__.py +58 -0
  10. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/cli.py +85 -0
  11. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/config.py +119 -0
  12. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/hmac_auth.py +37 -0
  13. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/livekit_room.py +293 -0
  14. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/log.py +52 -0
  15. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/metrics.py +46 -0
  16. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/protocol.py +32 -0
  17. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/py.typed +0 -0
  18. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/server.py +392 -0
  19. livekit_msteams_bridge-0.1.0/src/livekit_msteams_bridge/session.py +473 -0
  20. livekit_msteams_bridge-0.1.0/website/src/content/docs/agents-and-dispatch.md +89 -0
  21. livekit_msteams_bridge-0.1.0/website/src/content/docs/architecture.md +69 -0
  22. livekit_msteams_bridge-0.1.0/website/src/content/docs/configuration-reference.md +58 -0
  23. livekit_msteams_bridge-0.1.0/website/src/content/docs/connecting-to-standin.md +77 -0
  24. livekit_msteams_bridge-0.1.0/website/src/content/docs/contributing.md +31 -0
  25. livekit_msteams_bridge-0.1.0/website/src/content/docs/example.md +82 -0
  26. livekit_msteams_bridge-0.1.0/website/src/content/docs/getting-started.md +100 -0
  27. livekit_msteams_bridge-0.1.0/website/src/content/docs/governors-and-privacy.md +35 -0
  28. livekit_msteams_bridge-0.1.0/website/src/content/docs/index.mdx +38 -0
  29. livekit_msteams_bridge-0.1.0/website/src/content/docs/library-api.md +82 -0
  30. livekit_msteams_bridge-0.1.0/website/src/content/docs/troubleshooting.md +49 -0
  31. livekit_msteams_bridge-0.1.0/website/src/content/docs/wire-protocol.md +67 -0
  32. 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,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ .env
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
@@ -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()