diff --git a/CHANGELOG.md b/CHANGELOG.md index c2229e58..e95e08e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,17 @@ All notable changes to this project will be documented in this file. -## [0.3.1 - 2023-10-05] +## [0.3.1 - 2023-10-07] ### Added - CalendarAPI with the help of [caldav](https://pypi.org/project/caldav/) package. #136 - [NotesAPI](https://github.com/nextcloud/notes) #137 +- TalkAPI: `list_participants` method to list conversation participants. #142 + +### Fixed + +- TalkAPI: In One-to-One conversations the `status_message` and `status_icon` fields were always empty. ## [0.3.0 - 2023-09-28] diff --git a/docs/reference/Talk.rst b/docs/reference/Talk.rst index 8d532bcc..61d0f103 100644 --- a/docs/reference/Talk.rst +++ b/docs/reference/Talk.rst @@ -5,6 +5,10 @@ Talk API :members: :inherited-members: +.. autoclass:: nc_py_api.talk.Participant + :members: + :inherited-members: + .. autoclass:: nc_py_api.talk.TalkMessage :members: :inherited-members: diff --git a/nc_py_api/_talk_api.py b/nc_py_api/_talk_api.py index 2214e2c4..1a80a9f1 100644 --- a/nc_py_api/_talk_api.py +++ b/nc_py_api/_talk_api.py @@ -18,6 +18,7 @@ ConversationType, MessageReactions, NotificationLevel, + Participant, Poll, TalkFileMessage, TalkMessage, @@ -67,7 +68,7 @@ def get_user_conversations( if no_status_update: params["noStatusUpdate"] = 1 if include_status: - params["includeStatus"] = True + params["includeStatus"] = 1 if modified_since: params["modifiedSince"] = self.modified_since if modified_since is True else modified_since @@ -76,6 +77,20 @@ def get_user_conversations( self._update_config_sha() return [Conversation(i) for i in result] + def list_participants( + self, conversation: typing.Union[Conversation, str], include_status: bool = False + ) -> list[Participant]: + """Returns a list of conversation participants. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param include_status: Whether the user status information of all one-to-one conversations should be loaded. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + result = self._session.ocs( + "GET", self._ep_base + f"/api/v4/room/{token}/participants", params={"includeStatus": int(include_status)} + ) + return [Participant(i) for i in result] + def create_conversation( self, conversation_type: ConversationType, diff --git a/nc_py_api/talk.py b/nc_py_api/talk.py index 5095ebca..b1ef4225 100644 --- a/nc_py_api/talk.py +++ b/nc_py_api/talk.py @@ -6,7 +6,6 @@ import typing from . import files as _files -from .user_status import _UserStatus class ConversationType(enum.IntEnum): @@ -91,7 +90,7 @@ class ListableScope(enum.IntEnum): class NotificationLevel(enum.IntEnum): """The notification level for the user. - .. note:: Default: ``1`` for one-to-one conversations, ``2`` for other conversations. + .. note:: Default: ``1`` for ``one-to-one`` conversations, ``2`` for other conversations. """ DEFAULT = 0 @@ -309,8 +308,29 @@ def to_fs_node(self) -> _files.FsNode: ) +@dataclasses.dataclass +class _TalkUserStatus: + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def status_message(self) -> str: + """Message of the status.""" + return str(self._raw_data.get("statusMessage", "") or "") + + @property + def status_icon(self) -> str: + """The icon picked by the user (must be one emoji).""" + return str(self._raw_data.get("statusIcon", "") or "") + + @property + def status_type(self) -> str: + """Status type, on of the: online, away, dnd, invisible, offline.""" + return str(self._raw_data.get("status", "") or "") + + @dataclasses.dataclass(init=False) -class Conversation(_UserStatus): +class Conversation(_TalkUserStatus): """Talk conversation.""" @property @@ -447,7 +467,7 @@ def can_start_call(self) -> bool: def can_delete_conversation(self) -> bool: """Flag if the user can delete the conversation for everyone. - .. note: Not possible without moderator permissions or in one-to-one conversations. + .. note: Not possible without moderator permissions or in ``one-to-one`` conversations. """ return bool(self._raw_data.get("canDeleteConversation", False)) @@ -597,6 +617,74 @@ def recording_status(self) -> CallRecordingStatus: """ return CallRecordingStatus(self._raw_data.get("callRecording", CallRecordingStatus.NO_RECORDING)) + @property + def status_clear_at(self) -> typing.Optional[int]: + """Unix Timestamp representing the time to clear the status. + + .. note:: Available only for ``one-to-one`` conversations. + """ + return self._raw_data.get("statusClearAt", None) + + +@dataclasses.dataclass(init=False) +class Participant(_TalkUserStatus): + """Conversation participant information.""" + + @property + def attendee_id(self) -> int: + """Unique attendee id.""" + return self._raw_data["attendeeId"] + + @property + def actor_type(self) -> str: + """The actor type of the participant that voted: **users**, **groups**, **circles**, **guests**, **emails**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """The unique identifier for the given actor type.""" + return self._raw_data["actorId"] + + @property + def display_name(self) -> str: + """Can be empty for guests.""" + return self._raw_data["displayName"] + + @property + def participant_type(self) -> ParticipantType: + """Permissions level, see: :py:class:`~nc_py_api.talk.ParticipantType`.""" + return ParticipantType(self._raw_data["participantType"]) + + @property + def last_ping(self) -> int: + """Timestamp of the last ping. Should be used for sorting.""" + return self._raw_data["lastPing"] + + @property + def participant_flags(self) -> InCallFlags: + """Current call flags.""" + return InCallFlags(self._raw_data.get("inCall", InCallFlags.DISCONNECTED)) + + @property + def permissions(self) -> AttendeePermissions: + """Final permissions, combined :py:class:`~nc_py_api.talk.AttendeePermissions` values.""" + return AttendeePermissions(self._raw_data["permissions"]) + + @property + def attendee_permissions(self) -> AttendeePermissions: + """Dedicated permissions for the current participant, if not ``Custom``, they are not the resulting ones.""" + return AttendeePermissions(self._raw_data["attendeePermissions"]) + + @property + def session_ids(self) -> list[str]: + """A list of session IDs, each one 512 characters long, or empty if there is no session.""" + return self._raw_data["sessionIds"] + + @property + def breakout_token(self) -> str: + """Only available with breakout-rooms-v1 capability.""" + return self._raw_data.get("roomToken", "") + @dataclasses.dataclass class BotInfoBasic: diff --git a/nc_py_api/user_status.py b/nc_py_api/user_status.py index 6467a1e1..9fe74d56 100644 --- a/nc_py_api/user_status.py +++ b/nc_py_api/user_status.py @@ -47,9 +47,15 @@ def __init__(self, raw_status: dict): @dataclasses.dataclass -class _UserStatus: +class UserStatus: + """Information about user status.""" + + user_id: str + """The ID of the user this status is for""" + def __init__(self, raw_data: dict): self._raw_data = raw_data + self.user_id = raw_data["userId"] @property def status_message(self) -> str: @@ -72,18 +78,6 @@ def status_type(self) -> str: return self._raw_data.get("status", "") -@dataclasses.dataclass -class UserStatus(_UserStatus): - """Information about user status.""" - - user_id: str - """The ID of the user this status is for""" - - def __init__(self, raw_data: dict): - super().__init__(raw_data) - self.user_id = raw_data["userId"] - - @dataclasses.dataclass(init=False) class CurrentUserStatus(UserStatus): """Information about current user status.""" diff --git a/tests/actual_tests/talk_test.py b/tests/actual_tests/talk_test.py index 297089db..fc062b17 100644 --- a/tests/actual_tests/talk_test.py +++ b/tests/actual_tests/talk_test.py @@ -56,6 +56,10 @@ def test_conversation_create_delete(nc): assert isinstance(conversation.is_custom_avatar, bool) assert isinstance(conversation.call_start_time, int) assert isinstance(conversation.recording_status, talk.CallRecordingStatus) + assert isinstance(conversation.status_type, str) + assert isinstance(conversation.status_message, str) + assert isinstance(conversation.status_icon, str) + assert isinstance(conversation.status_clear_at, int) or conversation.status_clear_at is None if conversation.last_message is None: return talk_msg = conversation.last_message @@ -99,6 +103,7 @@ def test_get_conversations_include_status(nc, nc_client): pytest.skip("Nextcloud Talk is not installed") nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"]) nc_second_user.user_status.set_status_type("away") + nc_second_user.user_status.set_status("my status message", status_icon="😇") conversation = nc.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, environ["TEST_USER_ID"]) try: conversations = nc.talk.get_user_conversations(include_status=False) @@ -109,6 +114,26 @@ def test_get_conversations_include_status(nc, nc_client): assert conversations first_conv = next(i for i in conversations if i.conversation_id == conversation.conversation_id) assert first_conv.status_type == "away" + assert first_conv.status_message == "my status message" + assert first_conv.status_icon == "😇" + participants = nc.talk.list_participants(first_conv) + assert len(participants) == 2 + second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"]) + assert second_participant.actor_type == "users" + assert isinstance(second_participant.attendee_id, int) + assert isinstance(second_participant.display_name, str) + assert isinstance(second_participant.participant_type, talk.ParticipantType) + assert isinstance(second_participant.last_ping, int) + assert second_participant.participant_flags == talk.InCallFlags.DISCONNECTED + assert isinstance(second_participant.permissions, talk.AttendeePermissions) + assert isinstance(second_participant.attendee_permissions, talk.AttendeePermissions) + assert isinstance(second_participant.session_ids, list) + assert isinstance(second_participant.breakout_token, str) + assert second_participant.status_message == "" + participants = nc.talk.list_participants(first_conv, include_status=True) + assert len(participants) == 2 + second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"]) + assert second_participant.status_message == "my status message" finally: nc.talk.leave_conversation(conversation.token)