Skip to content

Commit cabdeb7

Browse files
committed
feat(rtc): add participant actions (register, discover, call)
1 parent 082e83c commit cabdeb7

4 files changed

Lines changed: 183 additions & 1 deletion

File tree

‎livekit-rtc/livekit/rtc/__init__.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@
115115
except Exception: # pragma: no cover - optional dependency (sounddevice)
116116
_HAS_MEDIA_DEVICES = False
117117
from .utils import combine_audio_frames
118+
from .actions import (
119+
ActionContext,
120+
ActionDeclinedError,
121+
ActionEntry,
122+
ActionRegistration,
123+
)
118124
from .rpc import RpcCallInfo, RpcError, RpcInterceptor, RpcInvocationData
119125
from .synchronizer import AVSynchronizer
120126
from .data_stream import (
@@ -209,6 +215,10 @@
209215
"AudioMixer",
210216
"AudioResampler",
211217
"AudioResamplerQuality",
218+
"ActionContext",
219+
"ActionDeclinedError",
220+
"ActionEntry",
221+
"ActionRegistration",
212222
"RpcError",
213223
"RpcInvocationData",
214224
"RpcCallInfo",

‎livekit-rtc/livekit/rtc/actions.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import json
5+
from dataclasses import asdict, dataclass, field
6+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
7+
8+
from .rpc import RpcError
9+
10+
ACTIONS_ATTRIBUTE = "lk.actions"
11+
ACTION_METHOD_PREFIX = "action:"
12+
ACTION_DECLINED_CODE = 1710
13+
14+
15+
@dataclass
16+
class ActionEntry:
17+
name: str
18+
description: str
19+
parameters: Dict[str, Any] = field(default_factory=dict)
20+
consent: str = "none"
21+
22+
23+
@dataclass
24+
class ActionContext:
25+
caller_identity: str
26+
27+
28+
class ActionDeclinedError(Exception):
29+
def __init__(self, message: str = "action declined") -> None:
30+
super().__init__(message)
31+
32+
33+
ActionHandler = Callable[[Dict[str, Any], ActionContext], Union[Any, Awaitable[Any]]]
34+
35+
36+
class ActionRegistration:
37+
def __init__(self, unregister: Callable[[], Awaitable[None]]) -> None:
38+
self._unregister = unregister
39+
40+
async def unregister(self) -> None:
41+
await self._unregister()
42+
43+
44+
def parse_actions(raw: Optional[str]) -> List[ActionEntry]:
45+
if not raw:
46+
return []
47+
try:
48+
items = json.loads(raw)
49+
except ValueError:
50+
return []
51+
if not isinstance(items, list):
52+
return []
53+
return [
54+
ActionEntry(
55+
name=item["name"],
56+
description=item.get("description", ""),
57+
parameters=item.get("parameters") or {},
58+
consent=item.get("consent", "none"),
59+
)
60+
for item in items
61+
if isinstance(item, dict) and isinstance(item.get("name"), str)
62+
]
63+
64+
65+
def serialize_actions(entries: List[ActionEntry]) -> str:
66+
return json.dumps([asdict(e) for e in entries])
67+
68+
69+
async def invoke_handler(handler: ActionHandler, payload: str, caller_identity: str) -> str:
70+
args = json.loads(payload) if payload else {}
71+
try:
72+
result = handler(args, ActionContext(caller_identity=caller_identity))
73+
if inspect.isawaitable(result):
74+
result = await result
75+
except ActionDeclinedError as e:
76+
raise RpcError(ACTION_DECLINED_CODE, str(e)) from e
77+
return json.dumps(result)

‎livekit-rtc/livekit/rtc/participant.py‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
import asyncio
1919
import functools
2020
import inspect
21+
import json
2122
import datetime
2223
import enum
2324
import os
2425
import mimetypes
2526
import weakref
26-
from typing import List, Union, Callable, Dict, Awaitable, Optional, Mapping, cast, TypeVar
27+
from typing import Any, List, Union, Callable, Dict, Awaitable, Optional, Mapping, cast, TypeVar
2728
from abc import abstractmethod, ABC
2829

2930
from ._ffi_client import FfiClient, FfiHandle
@@ -59,6 +60,18 @@
5960
_chain_outgoing,
6061
)
6162
from ._proto.rpc_pb2 import RpcMethodInvocationResponseRequest
63+
from .actions import (
64+
ACTION_DECLINED_CODE,
65+
ACTION_METHOD_PREFIX,
66+
ACTIONS_ATTRIBUTE,
67+
ActionDeclinedError,
68+
ActionEntry,
69+
ActionHandler,
70+
ActionRegistration,
71+
invoke_handler,
72+
parse_actions,
73+
serialize_actions,
74+
)
6275
from .log import logger
6376

6477
from .data_stream import (
@@ -173,6 +186,11 @@ def attributes(self) -> dict[str, str]:
173186
"""Custom attributes associated with the participant."""
174187
return dict(self._info.attributes)
175188

189+
@property
190+
def actions(self) -> List[ActionEntry]:
191+
"""Snapshot of the actions this participant exposes."""
192+
return parse_actions(self._info.attributes.get(ACTIONS_ATTRIBUTE))
193+
176194
@property
177195
def kind(self) -> proto_participant.ParticipantKind.ValueType:
178196
"""Participant's kind (e.g., regular participant, ingress, egress, sip, agent)."""
@@ -276,6 +294,7 @@ def __init__(
276294
self._track_publications: dict[str, LocalTrackPublication] = {}
277295
self._rpc_handlers: Dict[str, RpcHandler] = {}
278296
self._rpc_interceptors: List[RpcInterceptor] = []
297+
self._action_catalog: Dict[str, ActionEntry] = {}
279298
# Handles of data stream writers that have been opened but not yet
280299
# closed, so the room can drop them at disconnect. The FFI close
281300
# request consumes the handle (take_handle), so an entry is removed as
@@ -584,6 +603,74 @@ def unregister_rpc_method(self, method: str) -> None:
584603

585604
FfiClient.instance.request(req)
586605

606+
async def register_action(
607+
self,
608+
name: str,
609+
description: str,
610+
parameters: Dict[str, Any],
611+
handler: ActionHandler,
612+
*,
613+
consent: str = "none",
614+
) -> ActionRegistration:
615+
"""
616+
Expose an action other participants can discover and call.
617+
618+
The handler receives the parsed arguments and an `ActionContext`, may be sync or async,
619+
and returns a JSON-serializable result. Raise `ActionDeclinedError` to decline.
620+
"""
621+
method = ACTION_METHOD_PREFIX + name
622+
623+
async def rpc_handler(data: RpcInvocationData) -> str:
624+
return await invoke_handler(handler, data.payload, data.caller_identity)
625+
626+
self.register_rpc_method(method, rpc_handler)
627+
self._action_catalog[name] = ActionEntry(name, description, parameters, consent)
628+
await self._publish_actions()
629+
630+
async def unregister() -> None:
631+
self.unregister_rpc_method(method)
632+
self._action_catalog.pop(name, None)
633+
await self._publish_actions()
634+
635+
return ActionRegistration(unregister)
636+
637+
async def call_action(
638+
self,
639+
destination_identity: str,
640+
name: str,
641+
args: Optional[Dict[str, Any]] = None,
642+
*,
643+
response_timeout: Optional[float] = None,
644+
) -> Any:
645+
"""
646+
Call an action on another participant and return its parsed result.
647+
648+
Raises:
649+
ActionDeclinedError: The participant declined the call.
650+
RpcError: Unknown action, timeout, or other transport failure.
651+
"""
652+
try:
653+
response = await self.perform_rpc(
654+
destination_identity=destination_identity,
655+
method=ACTION_METHOD_PREFIX + name,
656+
payload=json.dumps(args or {}),
657+
response_timeout=response_timeout,
658+
)
659+
except RpcError as e:
660+
if e.code == ACTION_DECLINED_CODE:
661+
raise ActionDeclinedError(e.message) from e
662+
raise
663+
return json.loads(response) if response else None
664+
665+
async def _publish_actions(self) -> None:
666+
await self.set_attributes(
667+
{ACTIONS_ATTRIBUTE: serialize_actions(list(self._action_catalog.values()))}
668+
)
669+
670+
def _republish_actions(self) -> None:
671+
if self._action_catalog:
672+
asyncio.ensure_future(self._publish_actions())
673+
587674
def set_track_subscription_permissions(
588675
self,
589676
*,

‎livekit-rtc/livekit/rtc/room.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from ._proto.rpc_pb2 import RpcMethodInvocationEvent
3434
from ._utils import BroadcastQueue
3535
from .e2ee import E2EEManager, E2EEOptions
36+
from .actions import ACTIONS_ATTRIBUTE
3637
from .log import logger
3738
from .participant import (
3839
LocalParticipant,
@@ -72,6 +73,7 @@
7273
"participant_metadata_changed",
7374
"participant_name_changed",
7475
"participant_attributes_changed",
76+
"participant_actions_changed",
7577
"connection_quality_changed",
7678
"participant_encryption_status_changed",
7779
"participant_permissions_changed",
@@ -403,6 +405,8 @@ def on(self, event: EventTypes, callback: Optional[Callable] = None) -> Callable
403405
- Arguments: `participant` (Participant), `old_name` (str), `new_name` (str)
404406
- **"participant_attributes_changed"**: Called when a participant's attributes change.
405407
- Arguments: `changed_attributes` (dict), `participant` (Participant)
408+
- **"participant_actions_changed"**: Called when a participant's exposed actions change.
409+
- Arguments: `participant` (Participant), `actions` (list[ActionEntry])
406410
- **"participant_encryption_status_changed"**: Called when a participant's encryption status changes.
407411
- Arguments `is_encrypted` (bool), `participant` (Participant)
408412
- **"connection_quality_changed"**: Called when a participant's connection quality changes.
@@ -1030,6 +1034,8 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
10301034
changed_attributes,
10311035
participant,
10321036
)
1037+
if ACTIONS_ATTRIBUTE in changed_attributes:
1038+
self.emit("participant_actions_changed", participant.actions, participant)
10331039
elif which == "participant_encryption_status_changed":
10341040
identity = event.participant_encryption_status_changed.participant_identity
10351041
participant = self._retrieve_participant(identity)
@@ -1130,6 +1136,8 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
11301136
elif which == "reconnecting":
11311137
self.emit("reconnecting")
11321138
elif which == "reconnected":
1139+
if self._local_participant is not None:
1140+
self._local_participant._republish_actions()
11331141
self.emit("reconnected")
11341142
elif which == "text_stream_opened":
11351143
self._handle_text_stream_opened(event.text_stream_opened)

0 commit comments

Comments
 (0)