diff --git a/pyproject.toml b/pyproject.toml index 4dde69c..58dfb7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roamhq" -version = "0.0.1" +version = "0.1.0" description = "Official Python SDK for the Roam API" readme = "README.md" license = { text = "MIT" } diff --git a/src/roamhq/.fern/metadata.json b/src/roamhq/.fern/metadata.json new file mode 100644 index 0000000..e6bd928 --- /dev/null +++ b/src/roamhq/.fern/metadata.json @@ -0,0 +1,13 @@ +{ + "cliVersion": "5.82.0", + "generatorName": "fernapi/fern-python-sdk", + "generatorVersion": "5.29.3", + "generatorConfig": { + "package_name": "roamhq", + "client_class_name": "RoamClient" + }, + "originGitCommit": "e226955133092595744390682ba317d4ee33f224", + "originGitCommitIsDirty": false, + "invokedBy": "ci", + "ciProvider": "github" +} \ No newline at end of file diff --git a/src/roamhq/CONTRIBUTING.md b/src/roamhq/CONTRIBUTING.md new file mode 100644 index 0000000..af948ce --- /dev/null +++ b/src/roamhq/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing + +Thanks for your interest in contributing to this SDK! This document provides guidelines for contributing to the project. + +## Getting Started + +### Prerequisites + +- Python 3.9+ +- pip +- poetry + +### Installation + +Install the project dependencies: + +```bash +poetry install +``` + +### Building + +Build the project: + +```bash +poetry build +``` + +### Testing + +Run the test suite: + +```bash +poetry run pytest +``` + +### Linting and Formatting + +Check code style: + +```bash +poetry run ruff check . +poetry run ruff format . +``` + +### Type Checking + +Run the type checker: + +```bash +poetry run mypy . +``` + +## About Generated Code + +**Important**: Most files in this SDK are automatically generated by [Fern](https://buildwithfern.com) from the API definition. Direct modifications to generated files will be overwritten the next time the SDK is generated. + +### Generated Files + +The following directories contain generated code: +- `src/` - API client classes and types +- Most Python files in the project + +### How to Customize + +If you need to customize the SDK, you have two options: + +#### Option 1: Use `.fernignore` + +For custom code that should persist across SDK regenerations: + +1. Create a `.fernignore` file in the project root +2. Add file patterns for files you want to preserve (similar to `.gitignore` syntax) +3. Add your custom code to those files + +Files listed in `.fernignore` will not be overwritten when the SDK is regenerated. + +For more information, see the [Fern documentation on custom code](https://buildwithfern.com/learn/sdks/overview/custom-code). + +#### Option 2: Contribute to the Generator + +If you want to change how code is generated for all users of this SDK: + +1. The Python SDK generator lives in the [Fern repository](https://github.com/fern-api/fern) +2. Generator code is located at `generators/python-v2/` +3. Follow the [Fern contributing guidelines](https://github.com/fern-api/fern/blob/main/CONTRIBUTING.md) +4. Submit a pull request with your changes to the generator + +This approach is best for: +- Bug fixes in generated code +- New features that would benefit all users +- Improvements to code generation patterns + +## Making Changes + +### Workflow + +1. Create a new branch for your changes +2. Make your modifications +3. Run tests to ensure nothing breaks: `poetry run pytest` +4. Run linting and formatting: `poetry run ruff check .` and `poetry run ruff format .` +5. Run type checking: `poetry run mypy .` +6. Build the project: `poetry build` +7. Commit your changes with a clear commit message +8. Push your branch and create a pull request + +### Commit Messages + +Write clear, descriptive commit messages that explain what changed and why. + +### Code Style + +This project uses automated code formatting and linting. Run `poetry run ruff format .` and `poetry run ruff check .` before committing to ensure your code meets the project's style guidelines. + +## Questions or Issues? + +If you have questions or run into issues: + +1. Check the [Fern documentation](https://buildwithfern.com) +2. Search existing [GitHub issues](https://github.com/fern-api/fern/issues) +3. Open a new issue if your question hasn't been addressed + +## License + +By contributing to this project, you agree that your contributions will be licensed under the same license as the project. diff --git a/src/roamhq/__init__.py b/src/roamhq/__init__.py index 3476f8f..0d7f0a2 100644 --- a/src/roamhq/__init__.py +++ b/src/roamhq/__init__.py @@ -1,2 +1,573 @@ -# Placeholder so the seed tree is importable before the first regeneration. -# Fern overwrites this file; webhooks.py is fernignored and survives. +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + ActionItem, + Address, + AddressType, + ChatItem, + ChatItemType, + ChatMessage, + ChatMessageContentType, + ChatMessagePoll, + ChatMessagePollOptionsItem, + ChatMessageSender, + ChatMessageType, + ChatMessageUserType, + ChatMessageVoice, + Error, + Group, + GroupAccessMode, + GroupGroupManagement, + GroupMember, + GroupMemberRole, + GroupType, + LobbyBooking, + LobbyBookingHost, + LobbyBookingInvitee, + LobbyBookingResponse, + LobbyBookingResponseType, + LobbyBookingResponseValue, + Magicast, + MagicastChapter, + MagicastCue, + MagicastInfo, + MagicastInfoVideoStatus, + MeetingParticipant, + MeetingParticipantType, + Reaction, + Sender, + UnfurlContent, + UnfurlContentImage, + User, + UserActivity, + UserActivityDisplay, + UserActivityDisplayColor, + UserAuditLog, + UserAuditLogPlatform, + UserStatus, + UserType, + UserWillReturn, + Webhook, + WebhookEvent, + WebhookSubscriptionFilter, + WebhookSubscriptionFilterChatType, + WebhookSubscriptionFilterStatus, + ) + from .errors import ( + BadRequestError, + ConflictError, + ContentTooLargeError, + ForbiddenError, + InternalServerError, + MethodNotAllowedError, + NotFoundError, + TooManyRequestsError, + UnauthorizedError, + UnsupportedMediaTypeError, + ) + from . import ( + asset, + calendar, + chat, + conversation, + group, + groups, + item, + lobby, + magicast, + magicasts, + meeting, + meetings, + reaction, + story, + token, + user, + user_audit_log, + users, + webhook, + ) + from ._default_clients import DefaultAioHttpClient, DefaultAsyncHttpxClient + from .asset import CreateAssetRequestPurpose, CreateAssetResponse + from .calendar import ( + CreateEventCalendarResponse, + CreateEventCalendarResponseAttendeesItem, + CreateEventCalendarResponseMeetingLink, + ListCalendarResponse, + ListCalendarResponseEventsItem, + ListCalendarResponseEventsItemInvitesItem, + ) + from .chat import ( + AppendStreamChatResponse, + CancelScheduledChatResponse, + CreateLinkChatResponse, + DeleteChatResponse, + HistoryChatResponse, + ListChatResponse, + ListChatResponseChatsItem, + ListChatResponseChatsItemPreview, + ListChatResponseChatsItemPreviewContentType, + ListChatResponseChatsItemPreviewSender, + ListChatResponseChatsItemType, + ListScheduledChatResponse, + ListScheduledChatResponseScheduledMessagesItem, + PostChatRequestBlocksItem, + PostChatRequestBlocksItemType, + PostChatRequestPoll, + PostChatResponse, + PostEphemeralChatResponse, + ResolveLinkChatResponse, + SearchChatRequestChatTypesItem, + SearchChatRequestHasItem, + SearchChatRequestSort, + SearchChatResponse, + StartStreamChatRequestKind, + StartStreamChatResponse, + StopStreamChatResponse, + TypingChatRequestSender, + UnfurlChatResponse, + UpdateChatRequestBlocksItem, + UpdateChatRequestBlocksItemType, + UpdateChatResponse, + ) + from .client import AsyncRoamClient, RoamClient + from .conversation import ( + ListConversationResponse, + ListConversationResponseConversationsItem, + ListConversationResponseConversationsItemParticipantsItem, + ) + from .environment import RoamClientEnvironment + from .group import ( + AddGroupRequestMembersItem, + AddGroupRequestMembersItemRole, + CreateGroupRequestMembersItem, + CreateGroupRequestMembersItemRole, + ListGroupResponse, + ListGroupResponseGroupsItem, + ListGroupResponseGroupsItemAccessMode, + ListGroupResponseGroupsItemType, + MembersGroupResponse, + ) + from .groups import GroupsListResponseItem + from .lobby import ListBookingsLobbyResponse, ListLobbyResponse, ListLobbyResponseLobbiesItem + from .magicast import ListMagicastResponse + from .magicasts import MagicastShareLinkResponse + from .meeting import ( + CreateLinkMeetingResponse, + InfoMeetingResponse, + InfoMeetingResponseChaptersItem, + InfoMeetingResponseVideoStatus, + LinkInfoMeetingResponse, + ListMeetingResponse, + ListMeetingResponseMeetingsItem, + ListMeetingResponseMeetingsItemChaptersItem, + ListMeetingResponseMeetingsItemVideoStatus, + ParticipantsMeetingResponse, + PromptMeetingResponse, + SearchMeetingResponse, + SearchMeetingResponseInferredFilter, + SearchMeetingResponseResultsItem, + ShareLinkMeetingResponse, + TranscriptMeetingResponse, + TranscriptMeetingResponseCuesItem, + ) + from .meetings import RecordingListResponse, RecordingListResponseRecordingsItem + from .reaction import ListReactionResponse, ListReactionResponsePollVotesItem + from .story import PostStoryResponse + from .token import InfoTokenResponse, InfoTokenResponseBot, InfoTokenResponseRoam, InfoTokenResponseUser + from .user import ListUserResponse + from .user_audit_log import ListUserAuditLogResponse + from .users import UserActivityListResponse + from .webhook import ( + DeliveriesWebhookResponse, + DeliveriesWebhookResponseDeliveriesItem, + ListWebhookResponse, + ListWebhookResponseWebhooksItem, + WebhookSubscriptionRequestEvent, + ) +_dynamic_imports: typing.Dict[str, str] = { + "ActionItem": ".types", + "AddGroupRequestMembersItem": ".group", + "AddGroupRequestMembersItemRole": ".group", + "Address": ".types", + "AddressType": ".types", + "AppendStreamChatResponse": ".chat", + "AsyncRoamClient": ".client", + "BadRequestError": ".errors", + "CancelScheduledChatResponse": ".chat", + "ChatItem": ".types", + "ChatItemType": ".types", + "ChatMessage": ".types", + "ChatMessageContentType": ".types", + "ChatMessagePoll": ".types", + "ChatMessagePollOptionsItem": ".types", + "ChatMessageSender": ".types", + "ChatMessageType": ".types", + "ChatMessageUserType": ".types", + "ChatMessageVoice": ".types", + "ConflictError": ".errors", + "ContentTooLargeError": ".errors", + "CreateAssetRequestPurpose": ".asset", + "CreateAssetResponse": ".asset", + "CreateEventCalendarResponse": ".calendar", + "CreateEventCalendarResponseAttendeesItem": ".calendar", + "CreateEventCalendarResponseMeetingLink": ".calendar", + "CreateGroupRequestMembersItem": ".group", + "CreateGroupRequestMembersItemRole": ".group", + "CreateLinkChatResponse": ".chat", + "CreateLinkMeetingResponse": ".meeting", + "DefaultAioHttpClient": "._default_clients", + "DefaultAsyncHttpxClient": "._default_clients", + "DeleteChatResponse": ".chat", + "DeliveriesWebhookResponse": ".webhook", + "DeliveriesWebhookResponseDeliveriesItem": ".webhook", + "Error": ".types", + "ForbiddenError": ".errors", + "Group": ".types", + "GroupAccessMode": ".types", + "GroupGroupManagement": ".types", + "GroupMember": ".types", + "GroupMemberRole": ".types", + "GroupType": ".types", + "GroupsListResponseItem": ".groups", + "HistoryChatResponse": ".chat", + "InfoMeetingResponse": ".meeting", + "InfoMeetingResponseChaptersItem": ".meeting", + "InfoMeetingResponseVideoStatus": ".meeting", + "InfoTokenResponse": ".token", + "InfoTokenResponseBot": ".token", + "InfoTokenResponseRoam": ".token", + "InfoTokenResponseUser": ".token", + "InternalServerError": ".errors", + "LinkInfoMeetingResponse": ".meeting", + "ListBookingsLobbyResponse": ".lobby", + "ListCalendarResponse": ".calendar", + "ListCalendarResponseEventsItem": ".calendar", + "ListCalendarResponseEventsItemInvitesItem": ".calendar", + "ListChatResponse": ".chat", + "ListChatResponseChatsItem": ".chat", + "ListChatResponseChatsItemPreview": ".chat", + "ListChatResponseChatsItemPreviewContentType": ".chat", + "ListChatResponseChatsItemPreviewSender": ".chat", + "ListChatResponseChatsItemType": ".chat", + "ListConversationResponse": ".conversation", + "ListConversationResponseConversationsItem": ".conversation", + "ListConversationResponseConversationsItemParticipantsItem": ".conversation", + "ListGroupResponse": ".group", + "ListGroupResponseGroupsItem": ".group", + "ListGroupResponseGroupsItemAccessMode": ".group", + "ListGroupResponseGroupsItemType": ".group", + "ListLobbyResponse": ".lobby", + "ListLobbyResponseLobbiesItem": ".lobby", + "ListMagicastResponse": ".magicast", + "ListMeetingResponse": ".meeting", + "ListMeetingResponseMeetingsItem": ".meeting", + "ListMeetingResponseMeetingsItemChaptersItem": ".meeting", + "ListMeetingResponseMeetingsItemVideoStatus": ".meeting", + "ListReactionResponse": ".reaction", + "ListReactionResponsePollVotesItem": ".reaction", + "ListScheduledChatResponse": ".chat", + "ListScheduledChatResponseScheduledMessagesItem": ".chat", + "ListUserAuditLogResponse": ".user_audit_log", + "ListUserResponse": ".user", + "ListWebhookResponse": ".webhook", + "ListWebhookResponseWebhooksItem": ".webhook", + "LobbyBooking": ".types", + "LobbyBookingHost": ".types", + "LobbyBookingInvitee": ".types", + "LobbyBookingResponse": ".types", + "LobbyBookingResponseType": ".types", + "LobbyBookingResponseValue": ".types", + "Magicast": ".types", + "MagicastChapter": ".types", + "MagicastCue": ".types", + "MagicastInfo": ".types", + "MagicastInfoVideoStatus": ".types", + "MagicastShareLinkResponse": ".magicasts", + "MeetingParticipant": ".types", + "MeetingParticipantType": ".types", + "MembersGroupResponse": ".group", + "MethodNotAllowedError": ".errors", + "NotFoundError": ".errors", + "ParticipantsMeetingResponse": ".meeting", + "PostChatRequestBlocksItem": ".chat", + "PostChatRequestBlocksItemType": ".chat", + "PostChatRequestPoll": ".chat", + "PostChatResponse": ".chat", + "PostEphemeralChatResponse": ".chat", + "PostStoryResponse": ".story", + "PromptMeetingResponse": ".meeting", + "Reaction": ".types", + "RecordingListResponse": ".meetings", + "RecordingListResponseRecordingsItem": ".meetings", + "ResolveLinkChatResponse": ".chat", + "RoamClient": ".client", + "RoamClientEnvironment": ".environment", + "SearchChatRequestChatTypesItem": ".chat", + "SearchChatRequestHasItem": ".chat", + "SearchChatRequestSort": ".chat", + "SearchChatResponse": ".chat", + "SearchMeetingResponse": ".meeting", + "SearchMeetingResponseInferredFilter": ".meeting", + "SearchMeetingResponseResultsItem": ".meeting", + "Sender": ".types", + "ShareLinkMeetingResponse": ".meeting", + "StartStreamChatRequestKind": ".chat", + "StartStreamChatResponse": ".chat", + "StopStreamChatResponse": ".chat", + "TooManyRequestsError": ".errors", + "TranscriptMeetingResponse": ".meeting", + "TranscriptMeetingResponseCuesItem": ".meeting", + "TypingChatRequestSender": ".chat", + "UnauthorizedError": ".errors", + "UnfurlChatResponse": ".chat", + "UnfurlContent": ".types", + "UnfurlContentImage": ".types", + "UnsupportedMediaTypeError": ".errors", + "UpdateChatRequestBlocksItem": ".chat", + "UpdateChatRequestBlocksItemType": ".chat", + "UpdateChatResponse": ".chat", + "User": ".types", + "UserActivity": ".types", + "UserActivityDisplay": ".types", + "UserActivityDisplayColor": ".types", + "UserActivityListResponse": ".users", + "UserAuditLog": ".types", + "UserAuditLogPlatform": ".types", + "UserStatus": ".types", + "UserType": ".types", + "UserWillReturn": ".types", + "Webhook": ".types", + "WebhookEvent": ".types", + "WebhookSubscriptionFilter": ".types", + "WebhookSubscriptionFilterChatType": ".types", + "WebhookSubscriptionFilterStatus": ".types", + "WebhookSubscriptionRequestEvent": ".webhook", + "asset": ".asset", + "calendar": ".calendar", + "chat": ".chat", + "conversation": ".conversation", + "group": ".group", + "groups": ".groups", + "item": ".item", + "lobby": ".lobby", + "magicast": ".magicast", + "magicasts": ".magicasts", + "meeting": ".meeting", + "meetings": ".meetings", + "reaction": ".reaction", + "story": ".story", + "token": ".token", + "user": ".user", + "user_audit_log": ".user_audit_log", + "users": ".users", + "webhook": ".webhook", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "ActionItem", + "AddGroupRequestMembersItem", + "AddGroupRequestMembersItemRole", + "Address", + "AddressType", + "AppendStreamChatResponse", + "AsyncRoamClient", + "BadRequestError", + "CancelScheduledChatResponse", + "ChatItem", + "ChatItemType", + "ChatMessage", + "ChatMessageContentType", + "ChatMessagePoll", + "ChatMessagePollOptionsItem", + "ChatMessageSender", + "ChatMessageType", + "ChatMessageUserType", + "ChatMessageVoice", + "ConflictError", + "ContentTooLargeError", + "CreateAssetRequestPurpose", + "CreateAssetResponse", + "CreateEventCalendarResponse", + "CreateEventCalendarResponseAttendeesItem", + "CreateEventCalendarResponseMeetingLink", + "CreateGroupRequestMembersItem", + "CreateGroupRequestMembersItemRole", + "CreateLinkChatResponse", + "CreateLinkMeetingResponse", + "DefaultAioHttpClient", + "DefaultAsyncHttpxClient", + "DeleteChatResponse", + "DeliveriesWebhookResponse", + "DeliveriesWebhookResponseDeliveriesItem", + "Error", + "ForbiddenError", + "Group", + "GroupAccessMode", + "GroupGroupManagement", + "GroupMember", + "GroupMemberRole", + "GroupType", + "GroupsListResponseItem", + "HistoryChatResponse", + "InfoMeetingResponse", + "InfoMeetingResponseChaptersItem", + "InfoMeetingResponseVideoStatus", + "InfoTokenResponse", + "InfoTokenResponseBot", + "InfoTokenResponseRoam", + "InfoTokenResponseUser", + "InternalServerError", + "LinkInfoMeetingResponse", + "ListBookingsLobbyResponse", + "ListCalendarResponse", + "ListCalendarResponseEventsItem", + "ListCalendarResponseEventsItemInvitesItem", + "ListChatResponse", + "ListChatResponseChatsItem", + "ListChatResponseChatsItemPreview", + "ListChatResponseChatsItemPreviewContentType", + "ListChatResponseChatsItemPreviewSender", + "ListChatResponseChatsItemType", + "ListConversationResponse", + "ListConversationResponseConversationsItem", + "ListConversationResponseConversationsItemParticipantsItem", + "ListGroupResponse", + "ListGroupResponseGroupsItem", + "ListGroupResponseGroupsItemAccessMode", + "ListGroupResponseGroupsItemType", + "ListLobbyResponse", + "ListLobbyResponseLobbiesItem", + "ListMagicastResponse", + "ListMeetingResponse", + "ListMeetingResponseMeetingsItem", + "ListMeetingResponseMeetingsItemChaptersItem", + "ListMeetingResponseMeetingsItemVideoStatus", + "ListReactionResponse", + "ListReactionResponsePollVotesItem", + "ListScheduledChatResponse", + "ListScheduledChatResponseScheduledMessagesItem", + "ListUserAuditLogResponse", + "ListUserResponse", + "ListWebhookResponse", + "ListWebhookResponseWebhooksItem", + "LobbyBooking", + "LobbyBookingHost", + "LobbyBookingInvitee", + "LobbyBookingResponse", + "LobbyBookingResponseType", + "LobbyBookingResponseValue", + "Magicast", + "MagicastChapter", + "MagicastCue", + "MagicastInfo", + "MagicastInfoVideoStatus", + "MagicastShareLinkResponse", + "MeetingParticipant", + "MeetingParticipantType", + "MembersGroupResponse", + "MethodNotAllowedError", + "NotFoundError", + "ParticipantsMeetingResponse", + "PostChatRequestBlocksItem", + "PostChatRequestBlocksItemType", + "PostChatRequestPoll", + "PostChatResponse", + "PostEphemeralChatResponse", + "PostStoryResponse", + "PromptMeetingResponse", + "Reaction", + "RecordingListResponse", + "RecordingListResponseRecordingsItem", + "ResolveLinkChatResponse", + "RoamClient", + "RoamClientEnvironment", + "SearchChatRequestChatTypesItem", + "SearchChatRequestHasItem", + "SearchChatRequestSort", + "SearchChatResponse", + "SearchMeetingResponse", + "SearchMeetingResponseInferredFilter", + "SearchMeetingResponseResultsItem", + "Sender", + "ShareLinkMeetingResponse", + "StartStreamChatRequestKind", + "StartStreamChatResponse", + "StopStreamChatResponse", + "TooManyRequestsError", + "TranscriptMeetingResponse", + "TranscriptMeetingResponseCuesItem", + "TypingChatRequestSender", + "UnauthorizedError", + "UnfurlChatResponse", + "UnfurlContent", + "UnfurlContentImage", + "UnsupportedMediaTypeError", + "UpdateChatRequestBlocksItem", + "UpdateChatRequestBlocksItemType", + "UpdateChatResponse", + "User", + "UserActivity", + "UserActivityDisplay", + "UserActivityDisplayColor", + "UserActivityListResponse", + "UserAuditLog", + "UserAuditLogPlatform", + "UserStatus", + "UserType", + "UserWillReturn", + "Webhook", + "WebhookEvent", + "WebhookSubscriptionFilter", + "WebhookSubscriptionFilterChatType", + "WebhookSubscriptionFilterStatus", + "WebhookSubscriptionRequestEvent", + "asset", + "calendar", + "chat", + "conversation", + "group", + "groups", + "item", + "lobby", + "magicast", + "magicasts", + "meeting", + "meetings", + "reaction", + "story", + "token", + "user", + "user_audit_log", + "users", + "webhook", +] diff --git a/src/roamhq/_default_clients.py b/src/roamhq/_default_clients.py new file mode 100644 index 0000000..5be084b --- /dev/null +++ b/src/roamhq/_default_clients.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import httpx + +SDK_DEFAULT_TIMEOUT = 60 + +try: + import httpx_aiohttp # type: ignore[import-not-found] +except ImportError: + + class DefaultAioHttpClient(httpx.AsyncClient): # type: ignore + def __init__(self, **kwargs: typing.Any) -> None: + raise RuntimeError("To use the aiohttp client, install the aiohttp extra: pip install roamhq[aiohttp]") + +else: + + class DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: typing.Any) -> None: + kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +class DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: typing.Any) -> None: + kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) diff --git a/src/roamhq/asset/__init__.py b/src/roamhq/asset/__init__.py new file mode 100644 index 0000000..c8c4f2d --- /dev/null +++ b/src/roamhq/asset/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import CreateAssetRequestPurpose, CreateAssetResponse +_dynamic_imports: typing.Dict[str, str] = {"CreateAssetRequestPurpose": ".types", "CreateAssetResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["CreateAssetRequestPurpose", "CreateAssetResponse"] diff --git a/src/roamhq/asset/client.py b/src/roamhq/asset/client.py new file mode 100644 index 0000000..8e0a96b --- /dev/null +++ b/src/roamhq/asset/client.py @@ -0,0 +1,292 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawAssetClient, RawAssetClient +from .types.create_asset_request_purpose import CreateAssetRequestPurpose +from .types.create_asset_response import CreateAssetResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class AssetClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawAssetClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawAssetClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawAssetClient + """ + return self._raw_client + + def create( + self, + *, + name: str, + size: typing.Optional[int] = OMIT, + purpose: typing.Optional[CreateAssetRequestPurpose] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateAssetResponse: + """ + Create a file asset and get back a self-describing instruction for + uploading its bytes — the JSON-friendly way to attach a file (image, PDF, + document, …) to a message, supply media for a story, or host an avatar + image. Unlike [`/item.upload`](https://developer.ro.am/docs/api/item-upload), which takes raw + bytes in the request body, every caller-visible step here is JSON in / + JSON out (so it can be driven from MCP and other tool-calling clients), + and the file bytes never pass through this API. + + **Flow:** + 1. `POST /asset.create` with the file `name` (include the extension, e.g. + `photo.png`) and, if known, its `size` in bytes. For stories, also pass + `purpose: "story"`. For avatars, pass `purpose: "avatar"` and `size` + (max 10 MiB). The response + is an upload instruction: `assetId`, `uploadUrl`, `uploadMethod`, and + `uploadHeaders`. Avatar responses also include `imageUrl`. + 2. Upload the raw bytes in a **single request**: use `uploadMethod` (a + `POST`) against `uploadUrl`, send every header from `uploadHeaders` + verbatim, and put the file in the request body. Send the headers exactly + as given — they authorize the upload and select the single-request + upload protocol; omitting any will cause the upload to fail. + 3. Processing (thumbnails, previews, 512×512 WebP for avatars) happens + automatically once the bytes land. There is no separate "complete" call. + 4. Once the asset is ready, use it: + - `purpose: "file"` (default) — attach via `assetIds` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) or + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) + - `purpose: "story"` — post via [`/story.post`](https://developer.ro.am/docs/api/story-post) + - `purpose: "avatar"` — pass `imageUrl` as `sender.imageUrl` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) (and related send endpoints), or + as `hosts[].imageUrl` on + [`/onair.event.create`](https://developer.ro.am/docs/onair-api/onair-event-create) / + [`/onair.event.update`](https://developer.ro.am/docs/onair-api/onair-event-update) + + A freshly-uploaded asset may take a few seconds to process (videos take + longer). Chat and story endpoints that consume the asset return a 400 with + a "still processing" message until processing completes. Avatar `imageUrl` + 404s until the image is ready — wait a moment after the upload returns + before posting it. + + The `uploadUrl` is short-lived; if it expires, call `asset.create` again for + a fresh instruction. Maximum file size is 5 GiB for `file` / `story`, and + 10 MiB for `avatar`. + + ## Purposes + + | Purpose | Use | Access | + |---------|-----|--------| + | `file` (default) | Chat message attachments | Organization and Personal | + | `story` | Story media (photo or video) | Personal only | + | `avatar` | `sender.imageUrl` and On-Air `hosts.imageUrl` | Organization and Personal | + + Story assets are owned by the authenticated user (stories are posted as you, + not as a bot) and expire about 48 hours after creation. Because the media + must outlive the story's 24-hour lifetime, call + [`/story.post`](https://developer.ro.am/docs/api/story-post) within about 23 hours of creating the + asset; after that the asset is rejected and a new one must be created. + + Avatar assets are public 512×512 WebP images. They do not expire. From + API version `2026-08-25`, `sender.imageUrl` and On-Air `hosts.imageUrl` + must be a Roam-hosted avatar URL (this `imageUrl`, or a legacy + `/card-images/` or `/photos/people/` URL). Third-party image URLs return + 400. See [API Versioning](https://developer.ro.am/docs/guides/api-versioning) and + [Sender Profiles](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. `purpose: "story"` is Personal only. + + **Required scope:** `item:write` for `purpose: "file"`; `chat:send_message` + or `chat:write` for `purpose: "story"`; any of `item:write`, + `chat:send_message`, `chat:write`, or `onair:write` for `purpose: "avatar"`. + + Parameters + ---------- + name : str + File name, including its extension (e.g. `report.pdf`). Processing determines the media type from the extension. + + size : typing.Optional[int] + File size in bytes, if known. The true size is enforced + server-side during the upload. Maximum 5 GiB. Required for + `purpose: "avatar"` (maximum 10 MiB). + + purpose : typing.Optional[CreateAssetRequestPurpose] + What the asset will be used for. `file` (default) for chat + message attachments; `story` for story media (Personal tokens + only); `avatar` for `sender.imageUrl` and On-Air host photos. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateAssetResponse + Upload instruction created. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.asset.create( + name="quarterly-report.pdf", + size=248173, + ) + """ + _response = self._raw_client.create(name=name, size=size, purpose=purpose, request_options=request_options) + return _response.data + + +class AsyncAssetClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawAssetClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawAssetClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawAssetClient + """ + return self._raw_client + + async def create( + self, + *, + name: str, + size: typing.Optional[int] = OMIT, + purpose: typing.Optional[CreateAssetRequestPurpose] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateAssetResponse: + """ + Create a file asset and get back a self-describing instruction for + uploading its bytes — the JSON-friendly way to attach a file (image, PDF, + document, …) to a message, supply media for a story, or host an avatar + image. Unlike [`/item.upload`](https://developer.ro.am/docs/api/item-upload), which takes raw + bytes in the request body, every caller-visible step here is JSON in / + JSON out (so it can be driven from MCP and other tool-calling clients), + and the file bytes never pass through this API. + + **Flow:** + 1. `POST /asset.create` with the file `name` (include the extension, e.g. + `photo.png`) and, if known, its `size` in bytes. For stories, also pass + `purpose: "story"`. For avatars, pass `purpose: "avatar"` and `size` + (max 10 MiB). The response + is an upload instruction: `assetId`, `uploadUrl`, `uploadMethod`, and + `uploadHeaders`. Avatar responses also include `imageUrl`. + 2. Upload the raw bytes in a **single request**: use `uploadMethod` (a + `POST`) against `uploadUrl`, send every header from `uploadHeaders` + verbatim, and put the file in the request body. Send the headers exactly + as given — they authorize the upload and select the single-request + upload protocol; omitting any will cause the upload to fail. + 3. Processing (thumbnails, previews, 512×512 WebP for avatars) happens + automatically once the bytes land. There is no separate "complete" call. + 4. Once the asset is ready, use it: + - `purpose: "file"` (default) — attach via `assetIds` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) or + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) + - `purpose: "story"` — post via [`/story.post`](https://developer.ro.am/docs/api/story-post) + - `purpose: "avatar"` — pass `imageUrl` as `sender.imageUrl` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) (and related send endpoints), or + as `hosts[].imageUrl` on + [`/onair.event.create`](https://developer.ro.am/docs/onair-api/onair-event-create) / + [`/onair.event.update`](https://developer.ro.am/docs/onair-api/onair-event-update) + + A freshly-uploaded asset may take a few seconds to process (videos take + longer). Chat and story endpoints that consume the asset return a 400 with + a "still processing" message until processing completes. Avatar `imageUrl` + 404s until the image is ready — wait a moment after the upload returns + before posting it. + + The `uploadUrl` is short-lived; if it expires, call `asset.create` again for + a fresh instruction. Maximum file size is 5 GiB for `file` / `story`, and + 10 MiB for `avatar`. + + ## Purposes + + | Purpose | Use | Access | + |---------|-----|--------| + | `file` (default) | Chat message attachments | Organization and Personal | + | `story` | Story media (photo or video) | Personal only | + | `avatar` | `sender.imageUrl` and On-Air `hosts.imageUrl` | Organization and Personal | + + Story assets are owned by the authenticated user (stories are posted as you, + not as a bot) and expire about 48 hours after creation. Because the media + must outlive the story's 24-hour lifetime, call + [`/story.post`](https://developer.ro.am/docs/api/story-post) within about 23 hours of creating the + asset; after that the asset is rejected and a new one must be created. + + Avatar assets are public 512×512 WebP images. They do not expire. From + API version `2026-08-25`, `sender.imageUrl` and On-Air `hosts.imageUrl` + must be a Roam-hosted avatar URL (this `imageUrl`, or a legacy + `/card-images/` or `/photos/people/` URL). Third-party image URLs return + 400. See [API Versioning](https://developer.ro.am/docs/guides/api-versioning) and + [Sender Profiles](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. `purpose: "story"` is Personal only. + + **Required scope:** `item:write` for `purpose: "file"`; `chat:send_message` + or `chat:write` for `purpose: "story"`; any of `item:write`, + `chat:send_message`, `chat:write`, or `onair:write` for `purpose: "avatar"`. + + Parameters + ---------- + name : str + File name, including its extension (e.g. `report.pdf`). Processing determines the media type from the extension. + + size : typing.Optional[int] + File size in bytes, if known. The true size is enforced + server-side during the upload. Maximum 5 GiB. Required for + `purpose: "avatar"` (maximum 10 MiB). + + purpose : typing.Optional[CreateAssetRequestPurpose] + What the asset will be used for. `file` (default) for chat + message attachments; `story` for story media (Personal tokens + only); `avatar` for `sender.imageUrl` and On-Air host photos. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateAssetResponse + Upload instruction created. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.asset.create( + name="quarterly-report.pdf", + size=248173, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create( + name=name, size=size, purpose=purpose, request_options=request_options + ) + return _response.data diff --git a/src/roamhq/asset/raw_client.py b/src/roamhq/asset/raw_client.py new file mode 100644 index 0000000..789b960 --- /dev/null +++ b/src/roamhq/asset/raw_client.py @@ -0,0 +1,438 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.create_asset_request_purpose import CreateAssetRequestPurpose +from .types.create_asset_response import CreateAssetResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawAssetClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def create( + self, + *, + name: str, + size: typing.Optional[int] = OMIT, + purpose: typing.Optional[CreateAssetRequestPurpose] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[CreateAssetResponse]: + """ + Create a file asset and get back a self-describing instruction for + uploading its bytes — the JSON-friendly way to attach a file (image, PDF, + document, …) to a message, supply media for a story, or host an avatar + image. Unlike [`/item.upload`](https://developer.ro.am/docs/api/item-upload), which takes raw + bytes in the request body, every caller-visible step here is JSON in / + JSON out (so it can be driven from MCP and other tool-calling clients), + and the file bytes never pass through this API. + + **Flow:** + 1. `POST /asset.create` with the file `name` (include the extension, e.g. + `photo.png`) and, if known, its `size` in bytes. For stories, also pass + `purpose: "story"`. For avatars, pass `purpose: "avatar"` and `size` + (max 10 MiB). The response + is an upload instruction: `assetId`, `uploadUrl`, `uploadMethod`, and + `uploadHeaders`. Avatar responses also include `imageUrl`. + 2. Upload the raw bytes in a **single request**: use `uploadMethod` (a + `POST`) against `uploadUrl`, send every header from `uploadHeaders` + verbatim, and put the file in the request body. Send the headers exactly + as given — they authorize the upload and select the single-request + upload protocol; omitting any will cause the upload to fail. + 3. Processing (thumbnails, previews, 512×512 WebP for avatars) happens + automatically once the bytes land. There is no separate "complete" call. + 4. Once the asset is ready, use it: + - `purpose: "file"` (default) — attach via `assetIds` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) or + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) + - `purpose: "story"` — post via [`/story.post`](https://developer.ro.am/docs/api/story-post) + - `purpose: "avatar"` — pass `imageUrl` as `sender.imageUrl` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) (and related send endpoints), or + as `hosts[].imageUrl` on + [`/onair.event.create`](https://developer.ro.am/docs/onair-api/onair-event-create) / + [`/onair.event.update`](https://developer.ro.am/docs/onair-api/onair-event-update) + + A freshly-uploaded asset may take a few seconds to process (videos take + longer). Chat and story endpoints that consume the asset return a 400 with + a "still processing" message until processing completes. Avatar `imageUrl` + 404s until the image is ready — wait a moment after the upload returns + before posting it. + + The `uploadUrl` is short-lived; if it expires, call `asset.create` again for + a fresh instruction. Maximum file size is 5 GiB for `file` / `story`, and + 10 MiB for `avatar`. + + ## Purposes + + | Purpose | Use | Access | + |---------|-----|--------| + | `file` (default) | Chat message attachments | Organization and Personal | + | `story` | Story media (photo or video) | Personal only | + | `avatar` | `sender.imageUrl` and On-Air `hosts.imageUrl` | Organization and Personal | + + Story assets are owned by the authenticated user (stories are posted as you, + not as a bot) and expire about 48 hours after creation. Because the media + must outlive the story's 24-hour lifetime, call + [`/story.post`](https://developer.ro.am/docs/api/story-post) within about 23 hours of creating the + asset; after that the asset is rejected and a new one must be created. + + Avatar assets are public 512×512 WebP images. They do not expire. From + API version `2026-08-25`, `sender.imageUrl` and On-Air `hosts.imageUrl` + must be a Roam-hosted avatar URL (this `imageUrl`, or a legacy + `/card-images/` or `/photos/people/` URL). Third-party image URLs return + 400. See [API Versioning](https://developer.ro.am/docs/guides/api-versioning) and + [Sender Profiles](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. `purpose: "story"` is Personal only. + + **Required scope:** `item:write` for `purpose: "file"`; `chat:send_message` + or `chat:write` for `purpose: "story"`; any of `item:write`, + `chat:send_message`, `chat:write`, or `onair:write` for `purpose: "avatar"`. + + Parameters + ---------- + name : str + File name, including its extension (e.g. `report.pdf`). Processing determines the media type from the extension. + + size : typing.Optional[int] + File size in bytes, if known. The true size is enforced + server-side during the upload. Maximum 5 GiB. Required for + `purpose: "avatar"` (maximum 10 MiB). + + purpose : typing.Optional[CreateAssetRequestPurpose] + What the asset will be used for. `file` (default) for chat + message attachments; `story` for story media (Personal tokens + only); `avatar` for `sender.imageUrl` and On-Air host photos. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CreateAssetResponse] + Upload instruction created. + """ + _response = self._client_wrapper.httpx_client.request( + "asset.create", + method="POST", + json={ + "name": name, + "size": size, + "purpose": purpose, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateAssetResponse, + parse_obj_as( + type_=CreateAssetResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawAssetClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def create( + self, + *, + name: str, + size: typing.Optional[int] = OMIT, + purpose: typing.Optional[CreateAssetRequestPurpose] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[CreateAssetResponse]: + """ + Create a file asset and get back a self-describing instruction for + uploading its bytes — the JSON-friendly way to attach a file (image, PDF, + document, …) to a message, supply media for a story, or host an avatar + image. Unlike [`/item.upload`](https://developer.ro.am/docs/api/item-upload), which takes raw + bytes in the request body, every caller-visible step here is JSON in / + JSON out (so it can be driven from MCP and other tool-calling clients), + and the file bytes never pass through this API. + + **Flow:** + 1. `POST /asset.create` with the file `name` (include the extension, e.g. + `photo.png`) and, if known, its `size` in bytes. For stories, also pass + `purpose: "story"`. For avatars, pass `purpose: "avatar"` and `size` + (max 10 MiB). The response + is an upload instruction: `assetId`, `uploadUrl`, `uploadMethod`, and + `uploadHeaders`. Avatar responses also include `imageUrl`. + 2. Upload the raw bytes in a **single request**: use `uploadMethod` (a + `POST`) against `uploadUrl`, send every header from `uploadHeaders` + verbatim, and put the file in the request body. Send the headers exactly + as given — they authorize the upload and select the single-request + upload protocol; omitting any will cause the upload to fail. + 3. Processing (thumbnails, previews, 512×512 WebP for avatars) happens + automatically once the bytes land. There is no separate "complete" call. + 4. Once the asset is ready, use it: + - `purpose: "file"` (default) — attach via `assetIds` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) or + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) + - `purpose: "story"` — post via [`/story.post`](https://developer.ro.am/docs/api/story-post) + - `purpose: "avatar"` — pass `imageUrl` as `sender.imageUrl` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) (and related send endpoints), or + as `hosts[].imageUrl` on + [`/onair.event.create`](https://developer.ro.am/docs/onair-api/onair-event-create) / + [`/onair.event.update`](https://developer.ro.am/docs/onair-api/onair-event-update) + + A freshly-uploaded asset may take a few seconds to process (videos take + longer). Chat and story endpoints that consume the asset return a 400 with + a "still processing" message until processing completes. Avatar `imageUrl` + 404s until the image is ready — wait a moment after the upload returns + before posting it. + + The `uploadUrl` is short-lived; if it expires, call `asset.create` again for + a fresh instruction. Maximum file size is 5 GiB for `file` / `story`, and + 10 MiB for `avatar`. + + ## Purposes + + | Purpose | Use | Access | + |---------|-----|--------| + | `file` (default) | Chat message attachments | Organization and Personal | + | `story` | Story media (photo or video) | Personal only | + | `avatar` | `sender.imageUrl` and On-Air `hosts.imageUrl` | Organization and Personal | + + Story assets are owned by the authenticated user (stories are posted as you, + not as a bot) and expire about 48 hours after creation. Because the media + must outlive the story's 24-hour lifetime, call + [`/story.post`](https://developer.ro.am/docs/api/story-post) within about 23 hours of creating the + asset; after that the asset is rejected and a new one must be created. + + Avatar assets are public 512×512 WebP images. They do not expire. From + API version `2026-08-25`, `sender.imageUrl` and On-Air `hosts.imageUrl` + must be a Roam-hosted avatar URL (this `imageUrl`, or a legacy + `/card-images/` or `/photos/people/` URL). Third-party image URLs return + 400. See [API Versioning](https://developer.ro.am/docs/guides/api-versioning) and + [Sender Profiles](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. `purpose: "story"` is Personal only. + + **Required scope:** `item:write` for `purpose: "file"`; `chat:send_message` + or `chat:write` for `purpose: "story"`; any of `item:write`, + `chat:send_message`, `chat:write`, or `onair:write` for `purpose: "avatar"`. + + Parameters + ---------- + name : str + File name, including its extension (e.g. `report.pdf`). Processing determines the media type from the extension. + + size : typing.Optional[int] + File size in bytes, if known. The true size is enforced + server-side during the upload. Maximum 5 GiB. Required for + `purpose: "avatar"` (maximum 10 MiB). + + purpose : typing.Optional[CreateAssetRequestPurpose] + What the asset will be used for. `file` (default) for chat + message attachments; `story` for story media (Personal tokens + only); `avatar` for `sender.imageUrl` and On-Air host photos. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CreateAssetResponse] + Upload instruction created. + """ + _response = await self._client_wrapper.httpx_client.request( + "asset.create", + method="POST", + json={ + "name": name, + "size": size, + "purpose": purpose, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateAssetResponse, + parse_obj_as( + type_=CreateAssetResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/asset/types/__init__.py b/src/roamhq/asset/types/__init__.py new file mode 100644 index 0000000..982006a --- /dev/null +++ b/src/roamhq/asset/types/__init__.py @@ -0,0 +1,40 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .create_asset_request_purpose import CreateAssetRequestPurpose + from .create_asset_response import CreateAssetResponse +_dynamic_imports: typing.Dict[str, str] = { + "CreateAssetRequestPurpose": ".create_asset_request_purpose", + "CreateAssetResponse": ".create_asset_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["CreateAssetRequestPurpose", "CreateAssetResponse"] diff --git a/src/roamhq/asset/types/create_asset_request_purpose.py b/src/roamhq/asset/types/create_asset_request_purpose.py new file mode 100644 index 0000000..20f748f --- /dev/null +++ b/src/roamhq/asset/types/create_asset_request_purpose.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +CreateAssetRequestPurpose = typing.Union[typing.Literal["file", "story", "avatar"], typing.Any] diff --git a/src/roamhq/asset/types/create_asset_response.py b/src/roamhq/asset/types/create_asset_response.py new file mode 100644 index 0000000..8c6185d --- /dev/null +++ b/src/roamhq/asset/types/create_asset_response.py @@ -0,0 +1,79 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class CreateAssetResponse(UniversalBaseModel): + asset_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="assetId"), + pydantic.Field( + alias="assetId", + description="ID of the created asset. Pass it to chat.post / chat.update\nvia `assetIds`, or to story.post, once the upload completes.", + ), + ] + """ + ID of the created asset. Pass it to chat.post / chat.update + via `assetIds`, or to story.post, once the upload completes. + """ + + upload_url: typing_extensions.Annotated[ + str, + FieldMetadata(alias="uploadUrl"), + pydantic.Field(alias="uploadUrl", description="URL to upload the file bytes to."), + ] + """ + URL to upload the file bytes to. + """ + + upload_method: typing_extensions.Annotated[ + str, + FieldMetadata(alias="uploadMethod"), + pydantic.Field(alias="uploadMethod", description="HTTP method to use for the upload request (always `POST`)."), + ] + """ + HTTP method to use for the upload request (always `POST`). + """ + + upload_headers: typing_extensions.Annotated[ + typing.Dict[str, str], + FieldMetadata(alias="uploadHeaders"), + pydantic.Field( + alias="uploadHeaders", + description="Headers to send verbatim on the upload request. They authorize\nthe upload and select the single-request upload protocol.", + ), + ] + """ + Headers to send verbatim on the upload request. They authorize + the upload and select the single-request upload protocol. + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field( + alias="imageUrl", + description='Present for `purpose: "avatar"`. Canonical public URL to pass\nas `sender.imageUrl` or On-Air `hosts[].imageUrl` after the\nupload completes. 404s until image processing finishes.', + ), + ] = None + """ + Present for `purpose: "avatar"`. Canonical public URL to pass + as `sender.imageUrl` or On-Air `hosts[].imageUrl` after the + upload completes. 404s until image processing finishes. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/calendar/__init__.py b/src/roamhq/calendar/__init__.py new file mode 100644 index 0000000..0c76789 --- /dev/null +++ b/src/roamhq/calendar/__init__.py @@ -0,0 +1,57 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + CreateEventCalendarResponse, + CreateEventCalendarResponseAttendeesItem, + CreateEventCalendarResponseMeetingLink, + ListCalendarResponse, + ListCalendarResponseEventsItem, + ListCalendarResponseEventsItemInvitesItem, + ) +_dynamic_imports: typing.Dict[str, str] = { + "CreateEventCalendarResponse": ".types", + "CreateEventCalendarResponseAttendeesItem": ".types", + "CreateEventCalendarResponseMeetingLink": ".types", + "ListCalendarResponse": ".types", + "ListCalendarResponseEventsItem": ".types", + "ListCalendarResponseEventsItemInvitesItem": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "CreateEventCalendarResponse", + "CreateEventCalendarResponseAttendeesItem", + "CreateEventCalendarResponseMeetingLink", + "ListCalendarResponse", + "ListCalendarResponseEventsItem", + "ListCalendarResponseEventsItemInvitesItem", +] diff --git a/src/roamhq/calendar/client.py b/src/roamhq/calendar/client.py new file mode 100644 index 0000000..2ec389d --- /dev/null +++ b/src/roamhq/calendar/client.py @@ -0,0 +1,412 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawCalendarClient, RawCalendarClient +from .types.create_event_calendar_response import CreateEventCalendarResponse +from .types.list_calendar_response import ListCalendarResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class CalendarClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawCalendarClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawCalendarClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawCalendarClient + """ + return self._raw_client + + def create_event( + self, + *, + title: str, + start: dt.datetime, + end: dt.datetime, + description: typing.Optional[str] = OMIT, + all_day: typing.Optional[bool] = OMIT, + rrule: typing.Optional[str] = OMIT, + time_zone: typing.Optional[str] = OMIT, + attendees: typing.Optional[typing.Sequence[str]] = OMIT, + host: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateEventCalendarResponse: + """ + Create a calendar event on the host's connected calendar. A Roam meeting link + is automatically attached and email notifications are sent to attendees. + + The event is written to the first active, writable calendar associated with the + host. The host must have a connected calendar provider (e.g. Google, Microsoft). + + **Recurring events:** Provide `rrule` to create a recurring series. A + `timeZone` is required for recurring events. + + **All-day events:** Set `allDay: true`; `start` and `end` are interpreted as + dates and normalized to UTC midnight. + + **Access:** Organization and Personal. For Organization tokens, the `host` email + is required and identifies the calendar owner. For Personal tokens, `host` + defaults to the authenticated user; if provided it must match the + authenticated user's email. + + **Required scope:** `calendar:write` + + Parameters + ---------- + title : str + Event title. + + start : dt.datetime + Event start time (RFC3339). For all-day events, the date portion is used. + + end : dt.datetime + Event end time (RFC3339). For all-day events, the date portion is used. + + description : typing.Optional[str] + (Optional) Event description. + + all_day : typing.Optional[bool] + Whether this is an all-day event. Defaults to false. + + rrule : typing.Optional[str] + (Optional) iCalendar RFC 5545 recurrence rule, e.g. `FREQ=WEEKLY;COUNT=10`. + When provided, `timeZone` is required. + + time_zone : typing.Optional[str] + IANA timezone name, e.g. `America/New_York`. Required for recurring + events; recommended for all events. Defaults to `UTC` when omitted. + + attendees : typing.Optional[typing.Sequence[str]] + Attendee email addresses. Each entry may be a plain email + (`user@example.com`) or an address string (`Name `). + + host : typing.Optional[str] + Calendar host email. Required for Organization tokens. For Personal + tokens, defaults to the authenticated user and, if provided, must + match the authenticated user's email. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateEventCalendarResponse + Calendar event created successfully. + + Examples + -------- + import datetime + + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.calendar.create_event( + title="Q1 Planning", + description="Plan Q1 roadmap", + start=datetime.datetime.fromisoformat( + "2026-02-15 14:00:00+00:00", + ), + end=datetime.datetime.fromisoformat( + "2026-02-15 15:00:00+00:00", + ), + time_zone="America/Los_Angeles", + attendees=["sam@example.com", "Alex Doe "], + host="host@example.com", + ) + """ + _response = self._raw_client.create_event( + title=title, + start=start, + end=end, + description=description, + all_day=all_day, + rrule=rrule, + time_zone=time_zone, + attendees=attendees, + host=host, + request_options=request_options, + ) + return _response.data + + def list( + self, + *, + start_date: typing.Optional[str] = None, + end_date: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListCalendarResponse: + """ + List events from the authenticated user's connected calendars within + a date range. + + Pulls events from every active personal calendar attached to the user + (e.g. Google, Microsoft) and merges them into a single chronological + list. Canceled events are omitted. + + **Date range:** Defaults to a 7-day window starting today (caller's + timezone). Pass `startDate` to shift the window's start; pass + `endDate` to set its end (inclusive). Both are interpreted as + `YYYY-MM-DD` in the caller's timezone. + + **Access:** Personal access only. Organization tokens do not have + access to individual calendars and receive a `400`. + + **Required scope:** `calendar:read` + + `meetings:read` also grants this endpoint, but only for API clients + registered **before 2026-07-29T00:00Z**. Clients registered on or after that + date must hold `calendar:read`, or the call fails with `403` / + `missing_scope`. See [Scopes](https://developer.ro.am/docs/guides/scopes). + + Parameters + ---------- + start_date : typing.Optional[str] + First day to include (`YYYY-MM-DD`, caller's timezone). Defaults to today. + + end_date : typing.Optional[str] + Last day to include (`YYYY-MM-DD`, caller's timezone, inclusive). + Defaults to seven days after the resolved `startDate`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListCalendarResponse + Calendar events retrieved successfully. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.calendar.list() + """ + _response = self._raw_client.list(start_date=start_date, end_date=end_date, request_options=request_options) + return _response.data + + +class AsyncCalendarClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawCalendarClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawCalendarClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawCalendarClient + """ + return self._raw_client + + async def create_event( + self, + *, + title: str, + start: dt.datetime, + end: dt.datetime, + description: typing.Optional[str] = OMIT, + all_day: typing.Optional[bool] = OMIT, + rrule: typing.Optional[str] = OMIT, + time_zone: typing.Optional[str] = OMIT, + attendees: typing.Optional[typing.Sequence[str]] = OMIT, + host: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateEventCalendarResponse: + """ + Create a calendar event on the host's connected calendar. A Roam meeting link + is automatically attached and email notifications are sent to attendees. + + The event is written to the first active, writable calendar associated with the + host. The host must have a connected calendar provider (e.g. Google, Microsoft). + + **Recurring events:** Provide `rrule` to create a recurring series. A + `timeZone` is required for recurring events. + + **All-day events:** Set `allDay: true`; `start` and `end` are interpreted as + dates and normalized to UTC midnight. + + **Access:** Organization and Personal. For Organization tokens, the `host` email + is required and identifies the calendar owner. For Personal tokens, `host` + defaults to the authenticated user; if provided it must match the + authenticated user's email. + + **Required scope:** `calendar:write` + + Parameters + ---------- + title : str + Event title. + + start : dt.datetime + Event start time (RFC3339). For all-day events, the date portion is used. + + end : dt.datetime + Event end time (RFC3339). For all-day events, the date portion is used. + + description : typing.Optional[str] + (Optional) Event description. + + all_day : typing.Optional[bool] + Whether this is an all-day event. Defaults to false. + + rrule : typing.Optional[str] + (Optional) iCalendar RFC 5545 recurrence rule, e.g. `FREQ=WEEKLY;COUNT=10`. + When provided, `timeZone` is required. + + time_zone : typing.Optional[str] + IANA timezone name, e.g. `America/New_York`. Required for recurring + events; recommended for all events. Defaults to `UTC` when omitted. + + attendees : typing.Optional[typing.Sequence[str]] + Attendee email addresses. Each entry may be a plain email + (`user@example.com`) or an address string (`Name `). + + host : typing.Optional[str] + Calendar host email. Required for Organization tokens. For Personal + tokens, defaults to the authenticated user and, if provided, must + match the authenticated user's email. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateEventCalendarResponse + Calendar event created successfully. + + Examples + -------- + import asyncio + import datetime + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.calendar.create_event( + title="Q1 Planning", + description="Plan Q1 roadmap", + start=datetime.datetime.fromisoformat( + "2026-02-15 14:00:00+00:00", + ), + end=datetime.datetime.fromisoformat( + "2026-02-15 15:00:00+00:00", + ), + time_zone="America/Los_Angeles", + attendees=["sam@example.com", "Alex Doe "], + host="host@example.com", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_event( + title=title, + start=start, + end=end, + description=description, + all_day=all_day, + rrule=rrule, + time_zone=time_zone, + attendees=attendees, + host=host, + request_options=request_options, + ) + return _response.data + + async def list( + self, + *, + start_date: typing.Optional[str] = None, + end_date: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListCalendarResponse: + """ + List events from the authenticated user's connected calendars within + a date range. + + Pulls events from every active personal calendar attached to the user + (e.g. Google, Microsoft) and merges them into a single chronological + list. Canceled events are omitted. + + **Date range:** Defaults to a 7-day window starting today (caller's + timezone). Pass `startDate` to shift the window's start; pass + `endDate` to set its end (inclusive). Both are interpreted as + `YYYY-MM-DD` in the caller's timezone. + + **Access:** Personal access only. Organization tokens do not have + access to individual calendars and receive a `400`. + + **Required scope:** `calendar:read` + + `meetings:read` also grants this endpoint, but only for API clients + registered **before 2026-07-29T00:00Z**. Clients registered on or after that + date must hold `calendar:read`, or the call fails with `403` / + `missing_scope`. See [Scopes](https://developer.ro.am/docs/guides/scopes). + + Parameters + ---------- + start_date : typing.Optional[str] + First day to include (`YYYY-MM-DD`, caller's timezone). Defaults to today. + + end_date : typing.Optional[str] + Last day to include (`YYYY-MM-DD`, caller's timezone, inclusive). + Defaults to seven days after the resolved `startDate`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListCalendarResponse + Calendar events retrieved successfully. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.calendar.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + start_date=start_date, end_date=end_date, request_options=request_options + ) + return _response.data diff --git a/src/roamhq/calendar/raw_client.py b/src/roamhq/calendar/raw_client.py new file mode 100644 index 0000000..4e53b2d --- /dev/null +++ b/src/roamhq/calendar/raw_client.py @@ -0,0 +1,659 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.create_event_calendar_response import CreateEventCalendarResponse +from .types.list_calendar_response import ListCalendarResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawCalendarClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def create_event( + self, + *, + title: str, + start: dt.datetime, + end: dt.datetime, + description: typing.Optional[str] = OMIT, + all_day: typing.Optional[bool] = OMIT, + rrule: typing.Optional[str] = OMIT, + time_zone: typing.Optional[str] = OMIT, + attendees: typing.Optional[typing.Sequence[str]] = OMIT, + host: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[CreateEventCalendarResponse]: + """ + Create a calendar event on the host's connected calendar. A Roam meeting link + is automatically attached and email notifications are sent to attendees. + + The event is written to the first active, writable calendar associated with the + host. The host must have a connected calendar provider (e.g. Google, Microsoft). + + **Recurring events:** Provide `rrule` to create a recurring series. A + `timeZone` is required for recurring events. + + **All-day events:** Set `allDay: true`; `start` and `end` are interpreted as + dates and normalized to UTC midnight. + + **Access:** Organization and Personal. For Organization tokens, the `host` email + is required and identifies the calendar owner. For Personal tokens, `host` + defaults to the authenticated user; if provided it must match the + authenticated user's email. + + **Required scope:** `calendar:write` + + Parameters + ---------- + title : str + Event title. + + start : dt.datetime + Event start time (RFC3339). For all-day events, the date portion is used. + + end : dt.datetime + Event end time (RFC3339). For all-day events, the date portion is used. + + description : typing.Optional[str] + (Optional) Event description. + + all_day : typing.Optional[bool] + Whether this is an all-day event. Defaults to false. + + rrule : typing.Optional[str] + (Optional) iCalendar RFC 5545 recurrence rule, e.g. `FREQ=WEEKLY;COUNT=10`. + When provided, `timeZone` is required. + + time_zone : typing.Optional[str] + IANA timezone name, e.g. `America/New_York`. Required for recurring + events; recommended for all events. Defaults to `UTC` when omitted. + + attendees : typing.Optional[typing.Sequence[str]] + Attendee email addresses. Each entry may be a plain email + (`user@example.com`) or an address string (`Name `). + + host : typing.Optional[str] + Calendar host email. Required for Organization tokens. For Personal + tokens, defaults to the authenticated user and, if provided, must + match the authenticated user's email. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CreateEventCalendarResponse] + Calendar event created successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "calendar.event.create", + method="POST", + json={ + "title": title, + "description": description, + "start": start, + "end": end, + "allDay": all_day, + "rrule": rrule, + "timeZone": time_zone, + "attendees": attendees, + "host": host, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateEventCalendarResponse, + parse_obj_as( + type_=CreateEventCalendarResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list( + self, + *, + start_date: typing.Optional[str] = None, + end_date: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListCalendarResponse]: + """ + List events from the authenticated user's connected calendars within + a date range. + + Pulls events from every active personal calendar attached to the user + (e.g. Google, Microsoft) and merges them into a single chronological + list. Canceled events are omitted. + + **Date range:** Defaults to a 7-day window starting today (caller's + timezone). Pass `startDate` to shift the window's start; pass + `endDate` to set its end (inclusive). Both are interpreted as + `YYYY-MM-DD` in the caller's timezone. + + **Access:** Personal access only. Organization tokens do not have + access to individual calendars and receive a `400`. + + **Required scope:** `calendar:read` + + `meetings:read` also grants this endpoint, but only for API clients + registered **before 2026-07-29T00:00Z**. Clients registered on or after that + date must hold `calendar:read`, or the call fails with `403` / + `missing_scope`. See [Scopes](https://developer.ro.am/docs/guides/scopes). + + Parameters + ---------- + start_date : typing.Optional[str] + First day to include (`YYYY-MM-DD`, caller's timezone). Defaults to today. + + end_date : typing.Optional[str] + Last day to include (`YYYY-MM-DD`, caller's timezone, inclusive). + Defaults to seven days after the resolved `startDate`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListCalendarResponse] + Calendar events retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "calendar.list", + method="GET", + params={ + "startDate": start_date, + "endDate": end_date, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListCalendarResponse, + parse_obj_as( + type_=ListCalendarResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawCalendarClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def create_event( + self, + *, + title: str, + start: dt.datetime, + end: dt.datetime, + description: typing.Optional[str] = OMIT, + all_day: typing.Optional[bool] = OMIT, + rrule: typing.Optional[str] = OMIT, + time_zone: typing.Optional[str] = OMIT, + attendees: typing.Optional[typing.Sequence[str]] = OMIT, + host: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[CreateEventCalendarResponse]: + """ + Create a calendar event on the host's connected calendar. A Roam meeting link + is automatically attached and email notifications are sent to attendees. + + The event is written to the first active, writable calendar associated with the + host. The host must have a connected calendar provider (e.g. Google, Microsoft). + + **Recurring events:** Provide `rrule` to create a recurring series. A + `timeZone` is required for recurring events. + + **All-day events:** Set `allDay: true`; `start` and `end` are interpreted as + dates and normalized to UTC midnight. + + **Access:** Organization and Personal. For Organization tokens, the `host` email + is required and identifies the calendar owner. For Personal tokens, `host` + defaults to the authenticated user; if provided it must match the + authenticated user's email. + + **Required scope:** `calendar:write` + + Parameters + ---------- + title : str + Event title. + + start : dt.datetime + Event start time (RFC3339). For all-day events, the date portion is used. + + end : dt.datetime + Event end time (RFC3339). For all-day events, the date portion is used. + + description : typing.Optional[str] + (Optional) Event description. + + all_day : typing.Optional[bool] + Whether this is an all-day event. Defaults to false. + + rrule : typing.Optional[str] + (Optional) iCalendar RFC 5545 recurrence rule, e.g. `FREQ=WEEKLY;COUNT=10`. + When provided, `timeZone` is required. + + time_zone : typing.Optional[str] + IANA timezone name, e.g. `America/New_York`. Required for recurring + events; recommended for all events. Defaults to `UTC` when omitted. + + attendees : typing.Optional[typing.Sequence[str]] + Attendee email addresses. Each entry may be a plain email + (`user@example.com`) or an address string (`Name `). + + host : typing.Optional[str] + Calendar host email. Required for Organization tokens. For Personal + tokens, defaults to the authenticated user and, if provided, must + match the authenticated user's email. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CreateEventCalendarResponse] + Calendar event created successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "calendar.event.create", + method="POST", + json={ + "title": title, + "description": description, + "start": start, + "end": end, + "allDay": all_day, + "rrule": rrule, + "timeZone": time_zone, + "attendees": attendees, + "host": host, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateEventCalendarResponse, + parse_obj_as( + type_=CreateEventCalendarResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list( + self, + *, + start_date: typing.Optional[str] = None, + end_date: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListCalendarResponse]: + """ + List events from the authenticated user's connected calendars within + a date range. + + Pulls events from every active personal calendar attached to the user + (e.g. Google, Microsoft) and merges them into a single chronological + list. Canceled events are omitted. + + **Date range:** Defaults to a 7-day window starting today (caller's + timezone). Pass `startDate` to shift the window's start; pass + `endDate` to set its end (inclusive). Both are interpreted as + `YYYY-MM-DD` in the caller's timezone. + + **Access:** Personal access only. Organization tokens do not have + access to individual calendars and receive a `400`. + + **Required scope:** `calendar:read` + + `meetings:read` also grants this endpoint, but only for API clients + registered **before 2026-07-29T00:00Z**. Clients registered on or after that + date must hold `calendar:read`, or the call fails with `403` / + `missing_scope`. See [Scopes](https://developer.ro.am/docs/guides/scopes). + + Parameters + ---------- + start_date : typing.Optional[str] + First day to include (`YYYY-MM-DD`, caller's timezone). Defaults to today. + + end_date : typing.Optional[str] + Last day to include (`YYYY-MM-DD`, caller's timezone, inclusive). + Defaults to seven days after the resolved `startDate`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListCalendarResponse] + Calendar events retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "calendar.list", + method="GET", + params={ + "startDate": start_date, + "endDate": end_date, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListCalendarResponse, + parse_obj_as( + type_=ListCalendarResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/calendar/types/__init__.py b/src/roamhq/calendar/types/__init__.py new file mode 100644 index 0000000..7495023 --- /dev/null +++ b/src/roamhq/calendar/types/__init__.py @@ -0,0 +1,55 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .create_event_calendar_response import CreateEventCalendarResponse + from .create_event_calendar_response_attendees_item import CreateEventCalendarResponseAttendeesItem + from .create_event_calendar_response_meeting_link import CreateEventCalendarResponseMeetingLink + from .list_calendar_response import ListCalendarResponse + from .list_calendar_response_events_item import ListCalendarResponseEventsItem + from .list_calendar_response_events_item_invites_item import ListCalendarResponseEventsItemInvitesItem +_dynamic_imports: typing.Dict[str, str] = { + "CreateEventCalendarResponse": ".create_event_calendar_response", + "CreateEventCalendarResponseAttendeesItem": ".create_event_calendar_response_attendees_item", + "CreateEventCalendarResponseMeetingLink": ".create_event_calendar_response_meeting_link", + "ListCalendarResponse": ".list_calendar_response", + "ListCalendarResponseEventsItem": ".list_calendar_response_events_item", + "ListCalendarResponseEventsItemInvitesItem": ".list_calendar_response_events_item_invites_item", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "CreateEventCalendarResponse", + "CreateEventCalendarResponseAttendeesItem", + "CreateEventCalendarResponseMeetingLink", + "ListCalendarResponse", + "ListCalendarResponseEventsItem", + "ListCalendarResponseEventsItemInvitesItem", +] diff --git a/src/roamhq/calendar/types/create_event_calendar_response.py b/src/roamhq/calendar/types/create_event_calendar_response.py new file mode 100644 index 0000000..017ee0d --- /dev/null +++ b/src/roamhq/calendar/types/create_event_calendar_response.py @@ -0,0 +1,50 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .create_event_calendar_response_attendees_item import CreateEventCalendarResponseAttendeesItem +from .create_event_calendar_response_meeting_link import CreateEventCalendarResponseMeetingLink + + +class CreateEventCalendarResponse(UniversalBaseModel): + id: str = pydantic.Field() + """ + Calendar event ID (provider-specific). + """ + + title: str + description: typing.Optional[str] = None + start: dt.datetime + end: dt.datetime + all_day: typing_extensions.Annotated[ + typing.Optional[bool], FieldMetadata(alias="allDay"), pydantic.Field(alias="allDay") + ] = None + attendees: typing.List[CreateEventCalendarResponseAttendeesItem] = pydantic.Field() + """ + Attendees as stored on the calendar event. + """ + + meeting_link: typing_extensions.Annotated[ + typing.Optional[CreateEventCalendarResponseMeetingLink], + FieldMetadata(alias="meetingLink"), + pydantic.Field(alias="meetingLink", description="The Roam meeting link attached to the event."), + ] = None + """ + The Roam meeting link attached to the event. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/calendar/types/create_event_calendar_response_attendees_item.py b/src/roamhq/calendar/types/create_event_calendar_response_attendees_item.py new file mode 100644 index 0000000..0eb6fda --- /dev/null +++ b/src/roamhq/calendar/types/create_event_calendar_response_attendees_item.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class CreateEventCalendarResponseAttendeesItem(UniversalBaseModel): + name: typing.Optional[str] = None + email: typing.Optional[str] = None + status: typing.Optional[str] = pydantic.Field(default=None) + """ + RSVP status, if provided by the calendar provider. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/calendar/types/create_event_calendar_response_meeting_link.py b/src/roamhq/calendar/types/create_event_calendar_response_meeting_link.py new file mode 100644 index 0000000..049d42d --- /dev/null +++ b/src/roamhq/calendar/types/create_event_calendar_response_meeting_link.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class CreateEventCalendarResponseMeetingLink(UniversalBaseModel): + """ + The Roam meeting link attached to the event. + """ + + id: typing.Optional[str] = None + url: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/calendar/types/list_calendar_response.py b/src/roamhq/calendar/types/list_calendar_response.py new file mode 100644 index 0000000..ffba4d6 --- /dev/null +++ b/src/roamhq/calendar/types/list_calendar_response.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .list_calendar_response_events_item import ListCalendarResponseEventsItem + + +class ListCalendarResponse(UniversalBaseModel): + events: typing.List[ListCalendarResponseEventsItem] + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/calendar/types/list_calendar_response_events_item.py b/src/roamhq/calendar/types/list_calendar_response_events_item.py new file mode 100644 index 0000000..64d90cb --- /dev/null +++ b/src/roamhq/calendar/types/list_calendar_response_events_item.py @@ -0,0 +1,108 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_calendar_response_events_item_invites_item import ListCalendarResponseEventsItemInvitesItem + + +class ListCalendarResponseEventsItem(UniversalBaseModel): + id: str = pydantic.Field() + """ + Provider-specific event ID. + """ + + title: str + description: typing.Optional[str] = None + start_time: typing_extensions.Annotated[ + dt.datetime, + FieldMetadata(alias="startTime"), + pydantic.Field(alias="startTime", description="Event start time (RFC3339, caller's timezone)."), + ] + """ + Event start time (RFC3339, caller's timezone). + """ + + end_time: typing_extensions.Annotated[ + dt.datetime, + FieldMetadata(alias="endTime"), + pydantic.Field(alias="endTime", description="Event end time (RFC3339, caller's timezone)."), + ] + """ + Event end time (RFC3339, caller's timezone). + """ + + weekday: str = pydantic.Field() + """ + Weekday name (`Monday`, `Tuesday`, …) of `startTime` in the caller's timezone. + """ + + all_day: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="allDay"), + pydantic.Field( + alias="allDay", + description="`true` for all-day events. All-day events are\nemitted at UTC midnight without timezone\nconversion.", + ), + ] = None + """ + `true` for all-day events. All-day events are + emitted at UTC midnight without timezone + conversion. + """ + + location: typing.Optional[str] = pydantic.Field(default=None) + """ + Conference URL if the event has video conferencing + attached (preferring video entry points). + """ + + invites: typing.List[ListCalendarResponseEventsItemInvitesItem] + organizer: typing.Optional[str] = pydantic.Field(default=None) + """ + Organizer email address. + """ + + rrule: typing.Optional[str] = pydantic.Field(default=None) + """ + iCalendar RFC 5545 recurrence rule for the master + event in a recurring series. Mutually exclusive + with `recurringEventId`. + """ + + recurring_event_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="recurringEventId"), + pydantic.Field( + alias="recurringEventId", + description="Master event ID when this event is one instance of\na recurring series.", + ), + ] = None + """ + Master event ID when this event is one instance of + a recurring series. + """ + + meeting_link_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="meetingLinkId"), + pydantic.Field(alias="meetingLinkId", description="Roam meeting link attached to the event, if any."), + ] = None + """ + Roam meeting link attached to the event, if any. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/calendar/types/list_calendar_response_events_item_invites_item.py b/src/roamhq/calendar/types/list_calendar_response_events_item_invites_item.py new file mode 100644 index 0000000..dd19f0b --- /dev/null +++ b/src/roamhq/calendar/types/list_calendar_response_events_item_invites_item.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class ListCalendarResponseEventsItemInvitesItem(UniversalBaseModel): + name: typing.Optional[str] = None + email: typing.Optional[str] = None + response_status: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="responseStatus"), + pydantic.Field(alias="responseStatus", description="RSVP status from the calendar provider."), + ] = None + """ + RSVP status from the calendar provider. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/__init__.py b/src/roamhq/chat/__init__.py new file mode 100644 index 0000000..2563c3b --- /dev/null +++ b/src/roamhq/chat/__init__.py @@ -0,0 +1,132 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + AppendStreamChatResponse, + CancelScheduledChatResponse, + CreateLinkChatResponse, + DeleteChatResponse, + HistoryChatResponse, + ListChatResponse, + ListChatResponseChatsItem, + ListChatResponseChatsItemPreview, + ListChatResponseChatsItemPreviewContentType, + ListChatResponseChatsItemPreviewSender, + ListChatResponseChatsItemType, + ListScheduledChatResponse, + ListScheduledChatResponseScheduledMessagesItem, + PostChatRequestBlocksItem, + PostChatRequestBlocksItemType, + PostChatRequestPoll, + PostChatResponse, + PostEphemeralChatResponse, + ResolveLinkChatResponse, + SearchChatRequestChatTypesItem, + SearchChatRequestHasItem, + SearchChatRequestSort, + SearchChatResponse, + StartStreamChatRequestKind, + StartStreamChatResponse, + StopStreamChatResponse, + TypingChatRequestSender, + UnfurlChatResponse, + UpdateChatRequestBlocksItem, + UpdateChatRequestBlocksItemType, + UpdateChatResponse, + ) +_dynamic_imports: typing.Dict[str, str] = { + "AppendStreamChatResponse": ".types", + "CancelScheduledChatResponse": ".types", + "CreateLinkChatResponse": ".types", + "DeleteChatResponse": ".types", + "HistoryChatResponse": ".types", + "ListChatResponse": ".types", + "ListChatResponseChatsItem": ".types", + "ListChatResponseChatsItemPreview": ".types", + "ListChatResponseChatsItemPreviewContentType": ".types", + "ListChatResponseChatsItemPreviewSender": ".types", + "ListChatResponseChatsItemType": ".types", + "ListScheduledChatResponse": ".types", + "ListScheduledChatResponseScheduledMessagesItem": ".types", + "PostChatRequestBlocksItem": ".types", + "PostChatRequestBlocksItemType": ".types", + "PostChatRequestPoll": ".types", + "PostChatResponse": ".types", + "PostEphemeralChatResponse": ".types", + "ResolveLinkChatResponse": ".types", + "SearchChatRequestChatTypesItem": ".types", + "SearchChatRequestHasItem": ".types", + "SearchChatRequestSort": ".types", + "SearchChatResponse": ".types", + "StartStreamChatRequestKind": ".types", + "StartStreamChatResponse": ".types", + "StopStreamChatResponse": ".types", + "TypingChatRequestSender": ".types", + "UnfurlChatResponse": ".types", + "UpdateChatRequestBlocksItem": ".types", + "UpdateChatRequestBlocksItemType": ".types", + "UpdateChatResponse": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "AppendStreamChatResponse", + "CancelScheduledChatResponse", + "CreateLinkChatResponse", + "DeleteChatResponse", + "HistoryChatResponse", + "ListChatResponse", + "ListChatResponseChatsItem", + "ListChatResponseChatsItemPreview", + "ListChatResponseChatsItemPreviewContentType", + "ListChatResponseChatsItemPreviewSender", + "ListChatResponseChatsItemType", + "ListScheduledChatResponse", + "ListScheduledChatResponseScheduledMessagesItem", + "PostChatRequestBlocksItem", + "PostChatRequestBlocksItemType", + "PostChatRequestPoll", + "PostChatResponse", + "PostEphemeralChatResponse", + "ResolveLinkChatResponse", + "SearchChatRequestChatTypesItem", + "SearchChatRequestHasItem", + "SearchChatRequestSort", + "SearchChatResponse", + "StartStreamChatRequestKind", + "StartStreamChatResponse", + "StopStreamChatResponse", + "TypingChatRequestSender", + "UnfurlChatResponse", + "UpdateChatRequestBlocksItem", + "UpdateChatRequestBlocksItemType", + "UpdateChatResponse", +] diff --git a/src/roamhq/chat/client.py b/src/roamhq/chat/client.py new file mode 100644 index 0000000..a0ad55b --- /dev/null +++ b/src/roamhq/chat/client.py @@ -0,0 +1,2982 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.sender import Sender +from ..types.unfurl_content import UnfurlContent +from .raw_client import AsyncRawChatClient, RawChatClient +from .types.append_stream_chat_response import AppendStreamChatResponse +from .types.cancel_scheduled_chat_response import CancelScheduledChatResponse +from .types.create_link_chat_response import CreateLinkChatResponse +from .types.delete_chat_response import DeleteChatResponse +from .types.history_chat_response import HistoryChatResponse +from .types.list_chat_response import ListChatResponse +from .types.list_scheduled_chat_response import ListScheduledChatResponse +from .types.post_chat_request_blocks_item import PostChatRequestBlocksItem +from .types.post_chat_request_poll import PostChatRequestPoll +from .types.post_chat_response import PostChatResponse +from .types.post_ephemeral_chat_response import PostEphemeralChatResponse +from .types.resolve_link_chat_response import ResolveLinkChatResponse +from .types.search_chat_request_chat_types_item import SearchChatRequestChatTypesItem +from .types.search_chat_request_has_item import SearchChatRequestHasItem +from .types.search_chat_request_sort import SearchChatRequestSort +from .types.search_chat_response import SearchChatResponse +from .types.start_stream_chat_request_kind import StartStreamChatRequestKind +from .types.start_stream_chat_response import StartStreamChatResponse +from .types.stop_stream_chat_response import StopStreamChatResponse +from .types.typing_chat_request_sender import TypingChatRequestSender +from .types.unfurl_chat_response import UnfurlChatResponse +from .types.update_chat_request_blocks_item import UpdateChatRequestBlocksItem +from .types.update_chat_response import UpdateChatResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class ChatClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawChatClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawChatClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawChatClient + """ + return self._raw_client + + def list( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListChatResponse: + """ + List accessible chats — DMs, multi-DMs, group chats, all-hands "team + Roam" groups, and meeting chats. + + **Personal access tokens** are backed by the user's inbox: chats are + ordered by most recent activity and include `lastMessageTime`, + `isUnread`, `preview`, `isMuted`, and `isPinned`. Bot threads (where + the user has unread replies) are returned as separate rows keyed by + `threadTimestamp`. + + **Organization tokens** receive the chats the bot has access to, + ordered by chat creation time. Inbox-derived fields + (`lastMessageTime`, `isUnread`, `preview`, `isMuted`, `isPinned`) are + not populated, since bot addresses do not accumulate inbox state for + normal messages — those are delivered via webhooks. + + Timestamps are returned in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Required scope:** `chat:read` + + Pass `expand=addresses` to include an address sidecar for chat participants + and preview senders. See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + Parameters + ---------- + limit : typing.Optional[int] + Number of chats to return per response. Default 10, max 50. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving chat participants and preview sender IDs. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListChatResponse + Chats retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.list() + """ + _response = self._raw_client.list(limit=limit, cursor=cursor, expand=expand, request_options=request_options) + return _response.data + + def post( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + thread_key: typing.Optional[str] = OMIT, + reply_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + poll: typing.Optional[PostChatRequestPoll] = OMIT, + sender: typing.Optional[Sender] = OMIT, + sync: typing.Optional[bool] = OMIT, + send_at: typing.Optional[dt.datetime] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PostChatResponse: + """ + Send a message to a chat. Messages can be plain markdown text, rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts, or polls. + + **Destination (ONE of the following is required):** + - `chatId` - Post to an existing chat by its ID + - `groupId` - Post to a group chat + - `userIds` - Post to a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple destinations (e.g., both `chatId` and `groupId`) will return a 400 error. + + Mentions use Slack's token syntax with Slack's semantics: `<@ID>` mentions a principal (a user or bot, e.g. `<@7861a4c6-765a-495d-898d-fae3d8fbba2d>` — resolvable via [`user.info`](https://developer.ro.am/docs/api/user-info)), `` mentions a group or channel, notifying its members (resolvable via [`group.info`](https://developer.ro.am/docs/api/group-info)), and `` notifies everyone in the chat. + When rendered in the client, the tag will automatically be replaced with the human-readable display name (or "everyone" for ``). + On write, either token form is accepted for any mentionable ID; the legacy `<@all>` broadcast alias is accepted; and a Slack-style `|label` suffix (e.g. `<@7861a4c6-…|Rob>`, ``) is accepted and ignored — the mentioned entity's live display name is always used. Write-side acceptance is identical on every [API version](https://developer.ro.am/docs/guides/api-versioning). Messages read back always carry bare canonical tokens, and `` for the broadcast — on API versions from `2026-08-07`; clients pinned to older versions read the older grammar (`<@ID>` for every mention, `<@all>`). Slack forms Roam does not implement are reserved and stay literal text: `<#ID>` channel links, ``, and ``. + + **Custom sender (optional):** see the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + - `sender.name` / `sender.imageUrl` are per-message display overrides, stored on the message itself. + - `sender.id` authors the message as a configured bot persona (Roam Administration > Developer > edit your app > Add Bot Persona). Ids that don't match a configured persona are accepted and ignored — the message is authored by the app's root identity. Sending never creates or renames personas. + - **Personal access tokens**: Reject the `sender` field with 400. PATs always post as their personal bot. + + **Access:** Organization tokens can post to chats the bot is a member of, + and to **public groups** in the workspace without joining. Personal tokens + can post only where the owner is a member (`403` `not_in_chat` for an + unjoined public group). Full membership matrix: + [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Post to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Post to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Post to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Reply to a specific thread by providing the thread's timestamp. + If the timestamp doesn't correspond to an existing message, a 400 error is returned. + Mutually exclusive with `threadKey`. + + thread_key : typing.Optional[str] + A stable external identifier used to group related messages into a thread. + On the first use of a given `threadKey`, a new message is posted and the resulting + thread timestamp is stored. Subsequent messages with the same `threadKey` are + automatically threaded under the original message. + + This is useful for external integrations (e.g. PagerDuty, Grafana, Sentry) that + want to thread related messages using their own identifiers (such as `dedup_key`, + `fingerprint`, or `group_id`) without tracking Roam's internal thread timestamps. + + Mutually exclusive with `threadTimestamp`. When `threadKey` is provided, the + response is always synchronous (equivalent to `sync: true`). + + reply_timestamp : typing.Optional[int] + Reply directly to a specific message by its timestamp. Unlike + `threadTimestamp` (which threads a reply under a parent message in a + group), `replyTimestamp` is a direct reply used in DMs — which have no + threads — and within an existing channel thread. Text messages only: + not supported together with `blocks` or `poll`. + + text : typing.Optional[str] + Message text in GitHub-flavored markdown + + markdown : typing.Optional[bool] + Text is markdown by default. If set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Combines with + `text`/`items`; not with `blocks` or `poll`. + + blocks : typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + poll : typing.Optional[PostChatRequestPoll] + Create a poll message. Mutually exclusive with `text`, `items`, and `blocks`. + + sender : typing.Optional[Sender] + + sync : typing.Optional[bool] + If set, the post will be performed synchronously and its timestamp returned. Incompatible with `sendAt`. + + send_at : typing.Optional[dt.datetime] + Schedule the message for later delivery (RFC 3339). Requirements: + - Must be in the **future** and within **30 days** + - Must fall on a **15-minute UTC boundary** (`:00`, `:15`, `:30`, or `:45`; seconds and sub-seconds zero) + - Incompatible with `sync`, `poll`, `threadKey`, and `replyTimestamp` + + When `sendAt` is set, the response is `{chatId, scheduledMessageId, sendAt}` + instead of an immediate message `timestamp`. + + Scheduled messages can be listed via + [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list) and canceled via + [`/chat.scheduled.cancel`](https://developer.ro.am/docs/api/chat-scheduled-cancel) until they send. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PostChatResponse + Message posted or scheduled successfully. Immediate posts return + `chatId` (and `timestamp` when `sync` is set). Scheduled posts + (`sendAt`) return `chatId`, `scheduledMessageId`, and `sendAt`. + All success bodies include `"ok": true` — see + [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.post( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + text="Hello from the **API**", + ) + """ + _response = self._raw_client.post( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + thread_key=thread_key, + reply_timestamp=reply_timestamp, + text=text, + markdown=markdown, + items=items, + asset_ids=asset_ids, + blocks=blocks, + color=color, + poll=poll, + sender=sender, + sync=sync, + send_at=send_at, + request_options=request_options, + ) + return _response.data + + def post_ephemeral( + self, + *, + chat_id: str, + user_id: str, + text: str, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PostEphemeralChatResponse: + """ + Post an **ephemeral message** — visible to a single member of a chat, with an + "Only you can see this" header — without posting anything the other members can + see. This is the standard way for a bot to respond privately in a shared + channel (the Roam equivalent of Slack's `chat.postEphemeral`). + + The target `userId` must be a member of the chat (for channels: a member of the + backing group), otherwise the request fails with `user_not_in_chat`. + + `text` is always rendered as GitHub-flavored markdown. Mention markup + (`<@USER_ID>`) is **not** supported in ephemeral messages. Block Kit `blocks` + are not currently supported. + + **Delivery semantics — read before using:** + - **Desktop and web only.** Mobile clients do not display ephemeral messages, + and no mobile push notification is sent. A recipient who only uses Roam on + mobile will never see the message. + - **Best-effort, at-most-once.** The message is delivered in real time to the + recipient's connected clients, and to recently-active offline clients when + they reconnect. A recipient who has been offline for several days (or has + never signed in on that device) silently misses it. There are no retries + and no delivery receipt. + - **Transient.** The message is never stored server-side. It disappears when + the recipient restarts their app, and it never appears in + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) or [`/chat.search`](https://developer.ro.am/docs/api/chat-search). + - **Not addressable.** It cannot be edited or deleted: + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) and [`/chat.delete`](https://developer.ro.am/docs/api/chat-delete) + against its `(chatId, timestamp)` return `message_not_found`. + - **No webhooks.** Posting an ephemeral message never triggers a + [`chat.message`](https://developer.ro.am/docs/webhooks/chat-message) event, so it cannot leak to + org-wide webhook consumers. + + Do not use ephemeral messages for anything the recipient must durably receive — + use a DM ([`/chat.post`](https://developer.ro.am/docs/api/chat-post) with `userIds`) for that. + + **Custom sender (optional):** same semantics as [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — + `sender.name` / `sender.imageUrl` apply a per-message display override, and + `sender.id` authors the message as a configured bot persona (unknown ids + are accepted and ignored). Personal access tokens reject the `sender` + field. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Required scope:** `chat:send_message` or `chat:write` + + **Access:** Organization and Personal. The organization bot or + personal-token **owner** must be a member of the chat (`403` `not_in_chat` + otherwise) — unlike [`/chat.post`](https://developer.ro.am/docs/api/chat-post), there is no + public-group carveout. Personal tokens send as the user's personal bot + and reject the `sender` field. + + Parameters + ---------- + chat_id : str + The chat to post into. Use [`/chat.list`](https://developer.ro.am/docs/api/chat-list) or a `chat.message` webhook payload to obtain chat IDs. + + user_id : str + The user who should see the message. Must be a member of the chat. + + text : str + Message text in GitHub-flavored markdown (always rendered as + markdown; there is no plain-text mode). Maximum 8,000 bytes. + Mention markup is not supported. + + thread_timestamp : typing.Optional[int] + Show the ephemeral message inside an existing thread instead of the + main channel view. Channels only — returns 400 in DMs and Multi-DMs. + The value is not validated against an existing thread: pass a real + thread's timestamp, or the message is keyed under a thread view the + recipient can never open and is effectively never seen. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PostEphemeralChatResponse + Ephemeral message accepted for delivery. The `(chatId, timestamp)` pair is + the identity the recipient's client renders the message under; it is not + addressable by any other endpoint. All success bodies include `"ok": true` — + see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.post_ephemeral( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + user_id="7861a4c6-765a-495d-898d-fae3d8fbba2d", + text="Only *you* can see this: your deploy token expires in 3 days.", + ) + """ + _response = self._raw_client.post_ephemeral( + chat_id=chat_id, + user_id=user_id, + text=text, + thread_timestamp=thread_timestamp, + sender=sender, + request_options=request_options, + ) + return _response.data + + def list_scheduled( + self, + *, + chat_id: typing.Optional[str] = None, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListScheduledChatResponse: + """ + Lists pending messages scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s `sendAt` + that have not been sent yet. Results are ordered ascending by `sendAt` (soonest + first). Sent and canceled messages are not returned. + + Only messages scheduled by the calling credential's bot identity are listed: + organization tokens of the same app share the app's bot identity (and therefore + see each other's scheduled messages), while personal access tokens have a + per-person bot identity and see only their own. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Only return messages scheduled for this chat. + + after : typing.Optional[dt.datetime] + Only return messages scheduled to send after this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + before : typing.Optional[dt.datetime] + Only return messages scheduled to send before this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + limit : typing.Optional[int] + The number of scheduled messages to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListScheduledChatResponse + OK + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.list_scheduled() + """ + _response = self._raw_client.list_scheduled( + chat_id=chat_id, after=after, before=before, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + def cancel_scheduled( + self, *, scheduled_message_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> CancelScheduledChatResponse: + """ + Cancels a pending message scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s + `sendAt`, so it will never be delivered. Pending scheduled messages can be + discovered with [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list). + + Only the credential's bot identity that scheduled the message may cancel it. A + `scheduledMessageId` scheduled by a different identity — or one that never + existed — returns `scheduled_message_not_found`; the endpoint does not reveal + whether such an id exists. Canceling a message that has already been sent + returns `scheduled_message_already_sent`. + + Cancellation is best-effort once the scheduled send time arrives: delivery of a + due message begins in the seconds after its `sendAt` boundary, and a cancel + issued inside that window may return success while the message is still + delivered. Cancel ahead of the scheduled time to be safe. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + scheduled_message_id : str + The id returned by `/chat.post` when the message was scheduled. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CancelScheduledChatResponse + Scheduled message canceled; it will not be delivered. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.cancel_scheduled( + scheduled_message_id="0197f9f0-5cc1-7d07-8a12-9e65a8a0c1b9", + ) + """ + _response = self._raw_client.cancel_scheduled( + scheduled_message_id=scheduled_message_id, request_options=request_options + ) + return _response.data + + def start_stream( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + kind: typing.Optional[StartStreamChatRequestKind] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> StartStreamChatResponse: + """ + Open a streaming message and post its first content. Streaming lets a bot + deliver a message incrementally — recipients see the text fill in live (with + a "typing…" indicator) instead of waiting for the full response. This is + useful for AI agents that produce text token-by-token. + + A stream has three steps, each its own request: + + 1. **[`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)** — open the stream and pick the destination. Returns a `streamId`. + 2. **[`/chat.appendStream`](https://developer.ro.am/docs/api/chat-append-stream)** — append chunks of text (call as many times as needed). + 3. **[`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream)** — finalize the stream into a single persisted message. + + Pass the `streamId` returned here to every subsequent `appendStream` and + `stopStream`. The sender, destination, and thread are fixed for the lifetime + of the stream. + + **Custom sender (optional):** same semantics as + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — `sender.name` / `sender.imageUrl` + apply a per-message display override to the finalized message, and + `sender.id` authors the stream as a configured bot persona (unknown ids + are accepted and ignored). The typing indicator shown while streaming uses + the override name when given, otherwise the persona's or app's configured + name. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. Organization tokens follow the + same public-group carveout as [`/chat.post`](https://developer.ro.am/docs/api/chat-post): the + bot may stream into a public group in its roam without joining. + Personal tokens can stream only where the owner is a member + (`403` `not_in_chat` for an unjoined public group) and reject the + `sender` field. + + **Required scope:** `chat:send_message` or `chat:write` + + ## Destination + + Provide exactly one of `chatId`, `groupId`, or `userIds`. If `text` is empty, + the destination is recorded but message creation is deferred until the first + non-empty `appendStream` or the `stopStream` call. + + ## Thinking streams + + Set `kind` to `thinking` to finalize the message as a thought-bubble; clients + show a "thinking…" indicator instead of "typing…". The default `kind` is `text`. + + ## Limits + + - Up to **10 concurrent streams per API client**. + - Only **one active stream per chat** at a time. + - Accumulated text may not exceed the regular message size limit. + + Parameters + ---------- + chat_id : typing.Optional[str] + Stream into an existing chat by ID (mutually exclusive with groupId/userIds). + + group_id : typing.Optional[str] + Stream into a group chat (mutually exclusive with chatId/userIds). + + user_ids : typing.Optional[typing.Sequence[str]] + Stream into a DM or Multi-DM with these users (mutually exclusive with chatId/groupId). + + kind : typing.Optional[StartStreamChatRequestKind] + Stream kind. `thinking` finalizes as a thought-bubble message. + + thread_timestamp : typing.Optional[int] + Optional thread to reply within. + + text : typing.Optional[str] + Optional initial text. May be empty to defer destination resolution until the first append/stop. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + StartStreamChatResponse + Stream started. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.start_stream( + group_id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + text="Let me look into that...", + ) + """ + _response = self._raw_client.start_stream( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + kind=kind, + thread_timestamp=thread_timestamp, + text=text, + sender=sender, + request_options=request_options, + ) + return _response.data + + def append_stream( + self, + *, + stream_id: str, + text: str, + snapshot: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AppendStreamChatResponse: + """ + Append a chunk of text to an open stream (see + [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)). Each chunk is broadcast + to recipients as a delta, so the message appears to fill in live. Call as + many times as needed before [`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : str + Text chunk to append. Required and non-empty. + + snapshot : typing.Optional[bool] + If `true`, **replace** the accumulated text with `text` (and broadcast it + as a full snapshot) instead of appending. Useful when the client holds the + canonical current state — for example after rewriting prior output. The + message size limit is applied to the new `text` alone. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AppendStreamChatResponse + Chunk appended. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.append_stream( + stream_id="018f5c8e-7d2a-7c4e-8f9a-1a2b3c4d5e6f", + text=" The answer is 42.", + ) + """ + _response = self._raw_client.append_stream( + stream_id=stream_id, text=text, snapshot=snapshot, request_options=request_options + ) + return _response.data + + def stop_stream( + self, + *, + stream_id: str, + text: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> StopStreamChatResponse: + """ + Finalize an open stream (see [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)) + into a single persisted chat message and return its timestamp. Optionally + include trailing `text` to append before finalizing. + + If the app never calls `stopStream` but has already streamed some text, the + server finalizes the buffered text into a message automatically. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : typing.Optional[str] + Optional trailing text appended before the message is finalized. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + StopStreamChatResponse + Stream finalized and message persisted. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.stop_stream( + stream_id="018f5c8e-7d2a-7c4e-8f9a-1a2b3c4d5e6f", + text=" Hope that helps!", + ) + """ + _response = self._raw_client.stop_stream(stream_id=stream_id, text=text, request_options=request_options) + return _response.data + + def update( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> UpdateChatResponse: + """ + Edit a previously posted bot message. The updated message can contain plain markdown text or rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts. + + The bot must own the message being updated (matched by address ID). Personal access tokens always send as their bot persona and may only edit messages that personal bot posted. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to update. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + text : typing.Optional[str] + Updated markdown-formatted text content. Required unless `blocks` is provided. + Cannot be combined with `blocks`. + + markdown : typing.Optional[bool] + Text is markdown by default. If this is set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. Cannot be combined with `blocks`. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Cannot be combined with `blocks`. + + blocks : typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UpdateChatResponse + Message updated successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.update( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + timestamp=1765602474760032, + text="Updated message content with **bold text**", + ) + """ + _response = self._raw_client.update( + chat_id=chat_id, + timestamp=timestamp, + thread_timestamp=thread_timestamp, + text=text, + markdown=markdown, + items=items, + asset_ids=asset_ids, + blocks=blocks, + color=color, + request_options=request_options, + ) + return _response.data + + def delete( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> DeleteChatResponse: + """ + Delete a previously posted bot message. The bot must own the message being deleted (matched by address ID). Personal access tokens always send as their bot persona and may only delete messages that personal bot posted. + + Deleting an already-deleted message is idempotent and returns success. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to delete. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeleteChatResponse + Message deleted successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.delete( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + timestamp=1765602474760032, + ) + """ + _response = self._raw_client.delete( + chat_id=chat_id, timestamp=timestamp, thread_timestamp=thread_timestamp, request_options=request_options + ) + return _response.data + + def typing( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[TypingChatRequestSender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Notify other chat participants that you are working on a response. + If they have the chat open, they will see "(Bot name) is typing...". + + The indicator lasts **6 seconds**. Re-send every **5 seconds** to keep + it visible while you work. Longer gaps will let it expire between pings. + + **Destination options (mutually exclusive):** + - `chatId` - Send to an existing chat by its ID + - `groupId` - Send to a group channel + - `userIds` - Send to a DM or Multi-DM with the specified users + + **Custom sender (optional):** pass `sender.id` to show the indicator as a + [configured bot persona](https://developer.ro.am/docs/guides/sender-profiles) — the persona's + configured name and avatar are used. Only `id` is accepted; `name` and + `imageUrl` are rejected on this endpoint. Selection is lookup-only: an id + that doesn't match a configured persona is accepted and ignored, and the + indicator shows the app's own identity (same for an omitted, empty, or `_` + id). Personal access tokens reject `sender` entirely. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Send to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Send to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Send to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Timestamp of the message being replied to. + + sender : typing.Optional[TypingChatRequestSender] + Optional configured bot persona to show the indicator as. Only + `id` is accepted — `name` and `imageUrl` are rejected on this + endpoint. Personal access tokens reject this field entirely. + See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.typing( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + ) + """ + _response = self._raw_client.typing( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + sender=sender, + request_options=request_options, + ) + return _response.data + + def history( + self, + *, + chat_id: typing.Optional[str] = None, + group_id: typing.Optional[str] = None, + user_ids: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + thread_timestamp: typing.Optional[float] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HistoryChatResponse: + """ + List messages in a chat, filtered by date range (after/before). + + Messages with `contentType` of `text`, `voice`, or `poll` are returned. System messages and other content types are excluded. + + **Specify ONE of the following:** + - `chatId` - Fetch from an existing chat by its ID + - `groupId` - Fetch from a group chat + - `userIds` - Fetch from a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple (e.g., both `chatId` and `groupId`) will return a 400 error. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent messages are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Date-only inputs (`YYYY-MM-DD`) + are interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization tokens need to be a **member** of the chat + (`403` `not_in_chat` otherwise). Personal tokens can read any chat the + owner can, including public groups in their roam they have not joined. + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + chat_id : typing.Optional[str] + The chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + group_id : typing.Optional[str] + Group chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + user_ids : typing.Optional[typing.Union[str, typing.Sequence[str]]] + User IDs to fetch DM/Multi-DM messages with. Either chatId, groupId, or userIds must be specified. + + thread_timestamp : typing.Optional[float] + Read replies of the message with this timestamp. Specified in microseconds. + + after : typing.Optional[str] + The datetime to begin listing messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "no filter". + + before : typing.Optional[str] + The datetime until which to list messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "now". + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + limit : typing.Optional[int] + Number of messages to return (default 10, max 200). + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving the sender (`userId`) and mentioned IDs on + each message to their display info. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HistoryChatResponse + Messages retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.history() + """ + _response = self._raw_client.history( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + after=after, + before=before, + cursor=cursor, + limit=limit, + expand=expand, + request_options=request_options, + ) + return _response.data + + def search( + self, + *, + query: typing.Optional[str] = OMIT, + in_: typing.Optional[typing.Sequence[str]] = OMIT, + from_: typing.Optional[typing.Sequence[str]] = OMIT, + with_: typing.Optional[typing.Sequence[str]] = OMIT, + before: typing.Optional[str] = OMIT, + after: typing.Optional[str] = OMIT, + has: typing.Optional[typing.Sequence[SearchChatRequestHasItem]] = OMIT, + chat_types: typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] = OMIT, + exclude_chat_ids: typing.Optional[typing.Sequence[str]] = OMIT, + exclude_user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + sort: typing.Optional[SearchChatRequestSort] = OMIT, + expand: typing.Optional[str] = OMIT, + limit: typing.Optional[int] = OMIT, + cursor: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> SearchChatResponse: + """ + Full-text search over the caller's accessible messages. Returns + full-fidelity messages — text, items, voice, polls, blocks, and + mentions — hydrated through the same pipeline as + [`/chat.history`](https://developer.ro.am/docs/api/chat-history). + + All fields are optional. With no parameters, the most recent messages + across all chat types (DMs, multi-DMs, group chats) are returned in + reverse chronological order. + + **Sort:** When omitted and `query` is empty, results are sorted + chronologically (newest first), since relevance scoring is meaningless + without search terms. Pass `sort: recent` to force chronological order + even with a text query. + + **Date filters:** `before` and `after` accept `YYYY-MM-DD`. Dates are + interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization and Personal. + + - **Personal tokens** search chats the owner can read, including public + groups in their roam they have not joined. + - **Organization tokens** search chats the bot is a **member** of, + plus unjoined **public** groups in the bot's roam (Slack + `search:read.public`). Private groups the bot is not in are excluded. + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) stays membership-only. + + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + query : typing.Optional[str] + Free-text search query. Empty matches all messages. + + in_ : typing.Optional[typing.Sequence[str]] + Group names to search within. + + from_ : typing.Optional[typing.Sequence[str]] + Filter to messages sent by these email addresses. + + with_ : typing.Optional[typing.Sequence[str]] + Filter to chats including these email addresses. + + before : typing.Optional[str] + Only include messages before this date (`YYYY-MM-DD`, caller's timezone). + + after : typing.Optional[str] + Only include messages on or after this date (`YYYY-MM-DD`, caller's timezone). + + has : typing.Optional[typing.Sequence[SearchChatRequestHasItem]] + Restrict to messages that contain a mention or an item. + + chat_types : typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] + Restrict to specific chat types. Defaults to all types + (channels, all-hands "team Roam" groups, and DMs). + + exclude_chat_ids : typing.Optional[typing.Sequence[str]] + Chat IDs to exclude from results. + + exclude_user_ids : typing.Optional[typing.Sequence[str]] + Sender user IDs to exclude from results. + + sort : typing.Optional[SearchChatRequestSort] + `relevant` (default) ranks by relevance to `query`; `recent` + sorts newest first. With an empty `query`, results are + sorted chronologically regardless. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — + include an `addresses` map resolving the sender (`userId`) and + mentioned IDs on each message to their display info. + + limit : typing.Optional[int] + Number of messages per page (max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SearchChatResponse + Search results. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.search( + after="2026-04-13", + limit=20, + ) + """ + _response = self._raw_client.search( + query=query, + in_=in_, + from_=from_, + with_=with_, + before=before, + after=after, + has=has, + chat_types=chat_types, + exclude_chat_ids=exclude_chat_ids, + exclude_user_ids=exclude_user_ids, + sort=sort, + expand=expand, + limit=limit, + cursor=cursor, + request_options=request_options, + ) + return _response.data + + def resolve_link( + self, *, link: str, request_options: typing.Optional[RequestOptions] = None + ) -> ResolveLinkChatResponse: + """ + Parse a Roam chat deep link (e.g. `https://ro.am/r/#/d/...`) and return the + referenced message. + + When the caller has access to the referenced chat, the full message is + returned and `readable` is `true`. The `message` object is the same + shape as a `chat.history`/`chat.search` message — same fields, same + mention rendering. When the caller lacks access, the response still + includes the message key (`chatId`, `timestamp`, and `threadTimestamp` + if applicable) with `readable: false` and no message content — suitable + for rendering a reference without leaking content. + + Use [`/chat.link.create`](https://developer.ro.am/docs/api/chat-link-create) for the reverse + operation — minting a shareable Roam link from a message the caller can + already read. + + **Access:** Organization and Personal. + + **Required scope:** `chat:history` + + Parameters + ---------- + link : str + A Roam chat deep link URL that contains a message reference. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ResolveLinkChatResponse + Link resolved. When `readable` is false the caller lacks access to the chat; only the message key is returned. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.resolve_link( + link="https://ro.am/r/#/d/abc123xyz/c/757dfe66-37b4-4772-baa5-8c86ec68c176?ts=1765602474760032", + ) + """ + _response = self._raw_client.resolve_link(link=link, request_options=request_options) + return _response.data + + def create_link( + self, + *, + timestamp: int, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateLinkChatResponse: + """ + Create a shareable Roam link to a specific chat message. Opening the link + in Roam navigates to that message in its chat. + + Identify the chat with exactly one of `chatId`, `groupId`, or `userIds`, + and the message by its `timestamp` (Unix microseconds), as returned by + [`/chat.history`](https://developer.ro.am/docs/api/chat-history), [`/chat.post`](https://developer.ro.am/docs/api/chat-post), + or webhook message events. For a thread reply, also pass the thread root's + timestamp as `threadTimestamp` — without it the reply will not be found. + + The message must exist and be readable by the caller; otherwise no link is + returned (`404` if the message does not exist, `403` if the caller is not a + member of the chat). The link itself does not grant access: recipients can + only open it if they are members of the chat. + + Use [`/chat.link.resolve`](https://developer.ro.am/docs/api/chat-link-resolve) for the reverse + operation — turning a Roam chat link back into the referenced message. + + **Access:** Organization and Personal. In Personal mode, only chats the + authenticated user can access are allowed. + + **Required scope:** `chat:history` + + Parameters + ---------- + timestamp : int + The message's timestamp in Unix microseconds. + + chat_id : typing.Optional[str] + ID of the chat containing the message. Exactly one of `chatId`, `groupId`, or `userIds` is required. + + group_id : typing.Optional[str] + ID of a group whose channel chat contains the message. + + user_ids : typing.Optional[typing.Sequence[str]] + User ID(s) identifying the DM or group DM containing the message. + + thread_timestamp : typing.Optional[int] + The thread root's timestamp in Unix microseconds. Required when the message is a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateLinkChatResponse + Link created successfully. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.create_link( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + timestamp=1765602474760032, + ) + """ + _response = self._raw_client.create_link( + timestamp=timestamp, + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + request_options=request_options, + ) + return _response.data + + def unfurl( + self, + *, + chat_id: str, + message_timestamp: int, + unfurls: typing.Dict[str, UnfurlContent], + request_options: typing.Optional[RequestOptions] = None, + ) -> UnfurlChatResponse: + """ + Attach app-provided preview cards to links in an existing text message. + Every map key must be an exact URL currently present in the message and + must match one of the app's registered unfurl domains. Validation is + atomic: if any entry is invalid, no previews are changed. + + App previews replace Roam-generated previews for the same exact URL while + preserving unrelated previews. The server does not fetch any URL supplied + in this request. + + **Access:** Organization only (API Key or OAuth). Register unfurl domains on + the API client first — see [Unfurling links](https://developer.ro.am/docs/guides/unfurling-links). + Personal Access Tokens cannot register domains or call this endpoint. + + **Required scope:** `links:write` + + Parameters + ---------- + chat_id : str + + message_timestamp : int + Timestamp of a top-level or threaded message in Unix microseconds. + + unfurls : typing.Dict[str, UnfurlContent] + Preview content keyed by the exact URL from the message. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UnfurlChatResponse + Preview cards applied successfully. + + Examples + -------- + from roamhq import RoamClient, UnfurlContent, UnfurlContentImage + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.chat.unfurl( + chat_id="8f3b9c2e-1a4d-4e7b-9c0a-2b6d1f5e3a7c", + message_timestamp=1748906400000000, + unfurls={ + "https://status.example.com/incidents/123": UnfurlContent( + title="Incident 123", + description="Investigating elevated errors", + site_name="PagerDuty", + favicon="https://status.example.com/favicon.png", + image=UnfurlContentImage( + url="https://status.example.com/incident.png", + type="image/png", + width=1200, + height=630, + alt="Incident status", + ), + ) + }, + ) + """ + _response = self._raw_client.unfurl( + chat_id=chat_id, message_timestamp=message_timestamp, unfurls=unfurls, request_options=request_options + ) + return _response.data + + +class AsyncChatClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawChatClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawChatClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawChatClient + """ + return self._raw_client + + async def list( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListChatResponse: + """ + List accessible chats — DMs, multi-DMs, group chats, all-hands "team + Roam" groups, and meeting chats. + + **Personal access tokens** are backed by the user's inbox: chats are + ordered by most recent activity and include `lastMessageTime`, + `isUnread`, `preview`, `isMuted`, and `isPinned`. Bot threads (where + the user has unread replies) are returned as separate rows keyed by + `threadTimestamp`. + + **Organization tokens** receive the chats the bot has access to, + ordered by chat creation time. Inbox-derived fields + (`lastMessageTime`, `isUnread`, `preview`, `isMuted`, `isPinned`) are + not populated, since bot addresses do not accumulate inbox state for + normal messages — those are delivered via webhooks. + + Timestamps are returned in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Required scope:** `chat:read` + + Pass `expand=addresses` to include an address sidecar for chat participants + and preview senders. See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + Parameters + ---------- + limit : typing.Optional[int] + Number of chats to return per response. Default 10, max 50. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving chat participants and preview sender IDs. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListChatResponse + Chats retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + limit=limit, cursor=cursor, expand=expand, request_options=request_options + ) + return _response.data + + async def post( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + thread_key: typing.Optional[str] = OMIT, + reply_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + poll: typing.Optional[PostChatRequestPoll] = OMIT, + sender: typing.Optional[Sender] = OMIT, + sync: typing.Optional[bool] = OMIT, + send_at: typing.Optional[dt.datetime] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PostChatResponse: + """ + Send a message to a chat. Messages can be plain markdown text, rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts, or polls. + + **Destination (ONE of the following is required):** + - `chatId` - Post to an existing chat by its ID + - `groupId` - Post to a group chat + - `userIds` - Post to a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple destinations (e.g., both `chatId` and `groupId`) will return a 400 error. + + Mentions use Slack's token syntax with Slack's semantics: `<@ID>` mentions a principal (a user or bot, e.g. `<@7861a4c6-765a-495d-898d-fae3d8fbba2d>` — resolvable via [`user.info`](https://developer.ro.am/docs/api/user-info)), `` mentions a group or channel, notifying its members (resolvable via [`group.info`](https://developer.ro.am/docs/api/group-info)), and `` notifies everyone in the chat. + When rendered in the client, the tag will automatically be replaced with the human-readable display name (or "everyone" for ``). + On write, either token form is accepted for any mentionable ID; the legacy `<@all>` broadcast alias is accepted; and a Slack-style `|label` suffix (e.g. `<@7861a4c6-…|Rob>`, ``) is accepted and ignored — the mentioned entity's live display name is always used. Write-side acceptance is identical on every [API version](https://developer.ro.am/docs/guides/api-versioning). Messages read back always carry bare canonical tokens, and `` for the broadcast — on API versions from `2026-08-07`; clients pinned to older versions read the older grammar (`<@ID>` for every mention, `<@all>`). Slack forms Roam does not implement are reserved and stay literal text: `<#ID>` channel links, ``, and ``. + + **Custom sender (optional):** see the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + - `sender.name` / `sender.imageUrl` are per-message display overrides, stored on the message itself. + - `sender.id` authors the message as a configured bot persona (Roam Administration > Developer > edit your app > Add Bot Persona). Ids that don't match a configured persona are accepted and ignored — the message is authored by the app's root identity. Sending never creates or renames personas. + - **Personal access tokens**: Reject the `sender` field with 400. PATs always post as their personal bot. + + **Access:** Organization tokens can post to chats the bot is a member of, + and to **public groups** in the workspace without joining. Personal tokens + can post only where the owner is a member (`403` `not_in_chat` for an + unjoined public group). Full membership matrix: + [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Post to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Post to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Post to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Reply to a specific thread by providing the thread's timestamp. + If the timestamp doesn't correspond to an existing message, a 400 error is returned. + Mutually exclusive with `threadKey`. + + thread_key : typing.Optional[str] + A stable external identifier used to group related messages into a thread. + On the first use of a given `threadKey`, a new message is posted and the resulting + thread timestamp is stored. Subsequent messages with the same `threadKey` are + automatically threaded under the original message. + + This is useful for external integrations (e.g. PagerDuty, Grafana, Sentry) that + want to thread related messages using their own identifiers (such as `dedup_key`, + `fingerprint`, or `group_id`) without tracking Roam's internal thread timestamps. + + Mutually exclusive with `threadTimestamp`. When `threadKey` is provided, the + response is always synchronous (equivalent to `sync: true`). + + reply_timestamp : typing.Optional[int] + Reply directly to a specific message by its timestamp. Unlike + `threadTimestamp` (which threads a reply under a parent message in a + group), `replyTimestamp` is a direct reply used in DMs — which have no + threads — and within an existing channel thread. Text messages only: + not supported together with `blocks` or `poll`. + + text : typing.Optional[str] + Message text in GitHub-flavored markdown + + markdown : typing.Optional[bool] + Text is markdown by default. If set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Combines with + `text`/`items`; not with `blocks` or `poll`. + + blocks : typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + poll : typing.Optional[PostChatRequestPoll] + Create a poll message. Mutually exclusive with `text`, `items`, and `blocks`. + + sender : typing.Optional[Sender] + + sync : typing.Optional[bool] + If set, the post will be performed synchronously and its timestamp returned. Incompatible with `sendAt`. + + send_at : typing.Optional[dt.datetime] + Schedule the message for later delivery (RFC 3339). Requirements: + - Must be in the **future** and within **30 days** + - Must fall on a **15-minute UTC boundary** (`:00`, `:15`, `:30`, or `:45`; seconds and sub-seconds zero) + - Incompatible with `sync`, `poll`, `threadKey`, and `replyTimestamp` + + When `sendAt` is set, the response is `{chatId, scheduledMessageId, sendAt}` + instead of an immediate message `timestamp`. + + Scheduled messages can be listed via + [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list) and canceled via + [`/chat.scheduled.cancel`](https://developer.ro.am/docs/api/chat-scheduled-cancel) until they send. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PostChatResponse + Message posted or scheduled successfully. Immediate posts return + `chatId` (and `timestamp` when `sync` is set). Scheduled posts + (`sendAt`) return `chatId`, `scheduledMessageId`, and `sendAt`. + All success bodies include `"ok": true` — see + [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.post( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + text="Hello from the **API**", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.post( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + thread_key=thread_key, + reply_timestamp=reply_timestamp, + text=text, + markdown=markdown, + items=items, + asset_ids=asset_ids, + blocks=blocks, + color=color, + poll=poll, + sender=sender, + sync=sync, + send_at=send_at, + request_options=request_options, + ) + return _response.data + + async def post_ephemeral( + self, + *, + chat_id: str, + user_id: str, + text: str, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PostEphemeralChatResponse: + """ + Post an **ephemeral message** — visible to a single member of a chat, with an + "Only you can see this" header — without posting anything the other members can + see. This is the standard way for a bot to respond privately in a shared + channel (the Roam equivalent of Slack's `chat.postEphemeral`). + + The target `userId` must be a member of the chat (for channels: a member of the + backing group), otherwise the request fails with `user_not_in_chat`. + + `text` is always rendered as GitHub-flavored markdown. Mention markup + (`<@USER_ID>`) is **not** supported in ephemeral messages. Block Kit `blocks` + are not currently supported. + + **Delivery semantics — read before using:** + - **Desktop and web only.** Mobile clients do not display ephemeral messages, + and no mobile push notification is sent. A recipient who only uses Roam on + mobile will never see the message. + - **Best-effort, at-most-once.** The message is delivered in real time to the + recipient's connected clients, and to recently-active offline clients when + they reconnect. A recipient who has been offline for several days (or has + never signed in on that device) silently misses it. There are no retries + and no delivery receipt. + - **Transient.** The message is never stored server-side. It disappears when + the recipient restarts their app, and it never appears in + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) or [`/chat.search`](https://developer.ro.am/docs/api/chat-search). + - **Not addressable.** It cannot be edited or deleted: + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) and [`/chat.delete`](https://developer.ro.am/docs/api/chat-delete) + against its `(chatId, timestamp)` return `message_not_found`. + - **No webhooks.** Posting an ephemeral message never triggers a + [`chat.message`](https://developer.ro.am/docs/webhooks/chat-message) event, so it cannot leak to + org-wide webhook consumers. + + Do not use ephemeral messages for anything the recipient must durably receive — + use a DM ([`/chat.post`](https://developer.ro.am/docs/api/chat-post) with `userIds`) for that. + + **Custom sender (optional):** same semantics as [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — + `sender.name` / `sender.imageUrl` apply a per-message display override, and + `sender.id` authors the message as a configured bot persona (unknown ids + are accepted and ignored). Personal access tokens reject the `sender` + field. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Required scope:** `chat:send_message` or `chat:write` + + **Access:** Organization and Personal. The organization bot or + personal-token **owner** must be a member of the chat (`403` `not_in_chat` + otherwise) — unlike [`/chat.post`](https://developer.ro.am/docs/api/chat-post), there is no + public-group carveout. Personal tokens send as the user's personal bot + and reject the `sender` field. + + Parameters + ---------- + chat_id : str + The chat to post into. Use [`/chat.list`](https://developer.ro.am/docs/api/chat-list) or a `chat.message` webhook payload to obtain chat IDs. + + user_id : str + The user who should see the message. Must be a member of the chat. + + text : str + Message text in GitHub-flavored markdown (always rendered as + markdown; there is no plain-text mode). Maximum 8,000 bytes. + Mention markup is not supported. + + thread_timestamp : typing.Optional[int] + Show the ephemeral message inside an existing thread instead of the + main channel view. Channels only — returns 400 in DMs and Multi-DMs. + The value is not validated against an existing thread: pass a real + thread's timestamp, or the message is keyed under a thread view the + recipient can never open and is effectively never seen. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PostEphemeralChatResponse + Ephemeral message accepted for delivery. The `(chatId, timestamp)` pair is + the identity the recipient's client renders the message under; it is not + addressable by any other endpoint. All success bodies include `"ok": true` — + see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.post_ephemeral( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + user_id="7861a4c6-765a-495d-898d-fae3d8fbba2d", + text="Only *you* can see this: your deploy token expires in 3 days.", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.post_ephemeral( + chat_id=chat_id, + user_id=user_id, + text=text, + thread_timestamp=thread_timestamp, + sender=sender, + request_options=request_options, + ) + return _response.data + + async def list_scheduled( + self, + *, + chat_id: typing.Optional[str] = None, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListScheduledChatResponse: + """ + Lists pending messages scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s `sendAt` + that have not been sent yet. Results are ordered ascending by `sendAt` (soonest + first). Sent and canceled messages are not returned. + + Only messages scheduled by the calling credential's bot identity are listed: + organization tokens of the same app share the app's bot identity (and therefore + see each other's scheduled messages), while personal access tokens have a + per-person bot identity and see only their own. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Only return messages scheduled for this chat. + + after : typing.Optional[dt.datetime] + Only return messages scheduled to send after this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + before : typing.Optional[dt.datetime] + Only return messages scheduled to send before this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + limit : typing.Optional[int] + The number of scheduled messages to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListScheduledChatResponse + OK + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.list_scheduled() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_scheduled( + chat_id=chat_id, after=after, before=before, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + async def cancel_scheduled( + self, *, scheduled_message_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> CancelScheduledChatResponse: + """ + Cancels a pending message scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s + `sendAt`, so it will never be delivered. Pending scheduled messages can be + discovered with [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list). + + Only the credential's bot identity that scheduled the message may cancel it. A + `scheduledMessageId` scheduled by a different identity — or one that never + existed — returns `scheduled_message_not_found`; the endpoint does not reveal + whether such an id exists. Canceling a message that has already been sent + returns `scheduled_message_already_sent`. + + Cancellation is best-effort once the scheduled send time arrives: delivery of a + due message begins in the seconds after its `sendAt` boundary, and a cancel + issued inside that window may return success while the message is still + delivered. Cancel ahead of the scheduled time to be safe. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + scheduled_message_id : str + The id returned by `/chat.post` when the message was scheduled. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CancelScheduledChatResponse + Scheduled message canceled; it will not be delivered. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.cancel_scheduled( + scheduled_message_id="0197f9f0-5cc1-7d07-8a12-9e65a8a0c1b9", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.cancel_scheduled( + scheduled_message_id=scheduled_message_id, request_options=request_options + ) + return _response.data + + async def start_stream( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + kind: typing.Optional[StartStreamChatRequestKind] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> StartStreamChatResponse: + """ + Open a streaming message and post its first content. Streaming lets a bot + deliver a message incrementally — recipients see the text fill in live (with + a "typing…" indicator) instead of waiting for the full response. This is + useful for AI agents that produce text token-by-token. + + A stream has three steps, each its own request: + + 1. **[`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)** — open the stream and pick the destination. Returns a `streamId`. + 2. **[`/chat.appendStream`](https://developer.ro.am/docs/api/chat-append-stream)** — append chunks of text (call as many times as needed). + 3. **[`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream)** — finalize the stream into a single persisted message. + + Pass the `streamId` returned here to every subsequent `appendStream` and + `stopStream`. The sender, destination, and thread are fixed for the lifetime + of the stream. + + **Custom sender (optional):** same semantics as + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — `sender.name` / `sender.imageUrl` + apply a per-message display override to the finalized message, and + `sender.id` authors the stream as a configured bot persona (unknown ids + are accepted and ignored). The typing indicator shown while streaming uses + the override name when given, otherwise the persona's or app's configured + name. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. Organization tokens follow the + same public-group carveout as [`/chat.post`](https://developer.ro.am/docs/api/chat-post): the + bot may stream into a public group in its roam without joining. + Personal tokens can stream only where the owner is a member + (`403` `not_in_chat` for an unjoined public group) and reject the + `sender` field. + + **Required scope:** `chat:send_message` or `chat:write` + + ## Destination + + Provide exactly one of `chatId`, `groupId`, or `userIds`. If `text` is empty, + the destination is recorded but message creation is deferred until the first + non-empty `appendStream` or the `stopStream` call. + + ## Thinking streams + + Set `kind` to `thinking` to finalize the message as a thought-bubble; clients + show a "thinking…" indicator instead of "typing…". The default `kind` is `text`. + + ## Limits + + - Up to **10 concurrent streams per API client**. + - Only **one active stream per chat** at a time. + - Accumulated text may not exceed the regular message size limit. + + Parameters + ---------- + chat_id : typing.Optional[str] + Stream into an existing chat by ID (mutually exclusive with groupId/userIds). + + group_id : typing.Optional[str] + Stream into a group chat (mutually exclusive with chatId/userIds). + + user_ids : typing.Optional[typing.Sequence[str]] + Stream into a DM or Multi-DM with these users (mutually exclusive with chatId/groupId). + + kind : typing.Optional[StartStreamChatRequestKind] + Stream kind. `thinking` finalizes as a thought-bubble message. + + thread_timestamp : typing.Optional[int] + Optional thread to reply within. + + text : typing.Optional[str] + Optional initial text. May be empty to defer destination resolution until the first append/stop. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + StartStreamChatResponse + Stream started. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.start_stream( + group_id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + text="Let me look into that...", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.start_stream( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + kind=kind, + thread_timestamp=thread_timestamp, + text=text, + sender=sender, + request_options=request_options, + ) + return _response.data + + async def append_stream( + self, + *, + stream_id: str, + text: str, + snapshot: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AppendStreamChatResponse: + """ + Append a chunk of text to an open stream (see + [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)). Each chunk is broadcast + to recipients as a delta, so the message appears to fill in live. Call as + many times as needed before [`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : str + Text chunk to append. Required and non-empty. + + snapshot : typing.Optional[bool] + If `true`, **replace** the accumulated text with `text` (and broadcast it + as a full snapshot) instead of appending. Useful when the client holds the + canonical current state — for example after rewriting prior output. The + message size limit is applied to the new `text` alone. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AppendStreamChatResponse + Chunk appended. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.append_stream( + stream_id="018f5c8e-7d2a-7c4e-8f9a-1a2b3c4d5e6f", + text=" The answer is 42.", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.append_stream( + stream_id=stream_id, text=text, snapshot=snapshot, request_options=request_options + ) + return _response.data + + async def stop_stream( + self, + *, + stream_id: str, + text: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> StopStreamChatResponse: + """ + Finalize an open stream (see [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)) + into a single persisted chat message and return its timestamp. Optionally + include trailing `text` to append before finalizing. + + If the app never calls `stopStream` but has already streamed some text, the + server finalizes the buffered text into a message automatically. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : typing.Optional[str] + Optional trailing text appended before the message is finalized. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + StopStreamChatResponse + Stream finalized and message persisted. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.stop_stream( + stream_id="018f5c8e-7d2a-7c4e-8f9a-1a2b3c4d5e6f", + text=" Hope that helps!", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.stop_stream(stream_id=stream_id, text=text, request_options=request_options) + return _response.data + + async def update( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> UpdateChatResponse: + """ + Edit a previously posted bot message. The updated message can contain plain markdown text or rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts. + + The bot must own the message being updated (matched by address ID). Personal access tokens always send as their bot persona and may only edit messages that personal bot posted. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to update. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + text : typing.Optional[str] + Updated markdown-formatted text content. Required unless `blocks` is provided. + Cannot be combined with `blocks`. + + markdown : typing.Optional[bool] + Text is markdown by default. If this is set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. Cannot be combined with `blocks`. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Cannot be combined with `blocks`. + + blocks : typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UpdateChatResponse + Message updated successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.update( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + timestamp=1765602474760032, + text="Updated message content with **bold text**", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.update( + chat_id=chat_id, + timestamp=timestamp, + thread_timestamp=thread_timestamp, + text=text, + markdown=markdown, + items=items, + asset_ids=asset_ids, + blocks=blocks, + color=color, + request_options=request_options, + ) + return _response.data + + async def delete( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> DeleteChatResponse: + """ + Delete a previously posted bot message. The bot must own the message being deleted (matched by address ID). Personal access tokens always send as their bot persona and may only delete messages that personal bot posted. + + Deleting an already-deleted message is idempotent and returns success. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to delete. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeleteChatResponse + Message deleted successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.delete( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + timestamp=1765602474760032, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.delete( + chat_id=chat_id, timestamp=timestamp, thread_timestamp=thread_timestamp, request_options=request_options + ) + return _response.data + + async def typing( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[TypingChatRequestSender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Notify other chat participants that you are working on a response. + If they have the chat open, they will see "(Bot name) is typing...". + + The indicator lasts **6 seconds**. Re-send every **5 seconds** to keep + it visible while you work. Longer gaps will let it expire between pings. + + **Destination options (mutually exclusive):** + - `chatId` - Send to an existing chat by its ID + - `groupId` - Send to a group channel + - `userIds` - Send to a DM or Multi-DM with the specified users + + **Custom sender (optional):** pass `sender.id` to show the indicator as a + [configured bot persona](https://developer.ro.am/docs/guides/sender-profiles) — the persona's + configured name and avatar are used. Only `id` is accepted; `name` and + `imageUrl` are rejected on this endpoint. Selection is lookup-only: an id + that doesn't match a configured persona is accepted and ignored, and the + indicator shows the app's own identity (same for an omitted, empty, or `_` + id). Personal access tokens reject `sender` entirely. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Send to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Send to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Send to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Timestamp of the message being replied to. + + sender : typing.Optional[TypingChatRequestSender] + Optional configured bot persona to show the indicator as. Only + `id` is accepted — `name` and `imageUrl` are rejected on this + endpoint. Personal access tokens reject this field entirely. + See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.typing( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.typing( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + sender=sender, + request_options=request_options, + ) + return _response.data + + async def history( + self, + *, + chat_id: typing.Optional[str] = None, + group_id: typing.Optional[str] = None, + user_ids: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + thread_timestamp: typing.Optional[float] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HistoryChatResponse: + """ + List messages in a chat, filtered by date range (after/before). + + Messages with `contentType` of `text`, `voice`, or `poll` are returned. System messages and other content types are excluded. + + **Specify ONE of the following:** + - `chatId` - Fetch from an existing chat by its ID + - `groupId` - Fetch from a group chat + - `userIds` - Fetch from a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple (e.g., both `chatId` and `groupId`) will return a 400 error. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent messages are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Date-only inputs (`YYYY-MM-DD`) + are interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization tokens need to be a **member** of the chat + (`403` `not_in_chat` otherwise). Personal tokens can read any chat the + owner can, including public groups in their roam they have not joined. + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + chat_id : typing.Optional[str] + The chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + group_id : typing.Optional[str] + Group chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + user_ids : typing.Optional[typing.Union[str, typing.Sequence[str]]] + User IDs to fetch DM/Multi-DM messages with. Either chatId, groupId, or userIds must be specified. + + thread_timestamp : typing.Optional[float] + Read replies of the message with this timestamp. Specified in microseconds. + + after : typing.Optional[str] + The datetime to begin listing messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "no filter". + + before : typing.Optional[str] + The datetime until which to list messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "now". + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + limit : typing.Optional[int] + Number of messages to return (default 10, max 200). + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving the sender (`userId`) and mentioned IDs on + each message to their display info. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HistoryChatResponse + Messages retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.history() + + + asyncio.run(main()) + """ + _response = await self._raw_client.history( + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + after=after, + before=before, + cursor=cursor, + limit=limit, + expand=expand, + request_options=request_options, + ) + return _response.data + + async def search( + self, + *, + query: typing.Optional[str] = OMIT, + in_: typing.Optional[typing.Sequence[str]] = OMIT, + from_: typing.Optional[typing.Sequence[str]] = OMIT, + with_: typing.Optional[typing.Sequence[str]] = OMIT, + before: typing.Optional[str] = OMIT, + after: typing.Optional[str] = OMIT, + has: typing.Optional[typing.Sequence[SearchChatRequestHasItem]] = OMIT, + chat_types: typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] = OMIT, + exclude_chat_ids: typing.Optional[typing.Sequence[str]] = OMIT, + exclude_user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + sort: typing.Optional[SearchChatRequestSort] = OMIT, + expand: typing.Optional[str] = OMIT, + limit: typing.Optional[int] = OMIT, + cursor: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> SearchChatResponse: + """ + Full-text search over the caller's accessible messages. Returns + full-fidelity messages — text, items, voice, polls, blocks, and + mentions — hydrated through the same pipeline as + [`/chat.history`](https://developer.ro.am/docs/api/chat-history). + + All fields are optional. With no parameters, the most recent messages + across all chat types (DMs, multi-DMs, group chats) are returned in + reverse chronological order. + + **Sort:** When omitted and `query` is empty, results are sorted + chronologically (newest first), since relevance scoring is meaningless + without search terms. Pass `sort: recent` to force chronological order + even with a text query. + + **Date filters:** `before` and `after` accept `YYYY-MM-DD`. Dates are + interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization and Personal. + + - **Personal tokens** search chats the owner can read, including public + groups in their roam they have not joined. + - **Organization tokens** search chats the bot is a **member** of, + plus unjoined **public** groups in the bot's roam (Slack + `search:read.public`). Private groups the bot is not in are excluded. + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) stays membership-only. + + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + query : typing.Optional[str] + Free-text search query. Empty matches all messages. + + in_ : typing.Optional[typing.Sequence[str]] + Group names to search within. + + from_ : typing.Optional[typing.Sequence[str]] + Filter to messages sent by these email addresses. + + with_ : typing.Optional[typing.Sequence[str]] + Filter to chats including these email addresses. + + before : typing.Optional[str] + Only include messages before this date (`YYYY-MM-DD`, caller's timezone). + + after : typing.Optional[str] + Only include messages on or after this date (`YYYY-MM-DD`, caller's timezone). + + has : typing.Optional[typing.Sequence[SearchChatRequestHasItem]] + Restrict to messages that contain a mention or an item. + + chat_types : typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] + Restrict to specific chat types. Defaults to all types + (channels, all-hands "team Roam" groups, and DMs). + + exclude_chat_ids : typing.Optional[typing.Sequence[str]] + Chat IDs to exclude from results. + + exclude_user_ids : typing.Optional[typing.Sequence[str]] + Sender user IDs to exclude from results. + + sort : typing.Optional[SearchChatRequestSort] + `relevant` (default) ranks by relevance to `query`; `recent` + sorts newest first. With an empty `query`, results are + sorted chronologically regardless. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — + include an `addresses` map resolving the sender (`userId`) and + mentioned IDs on each message to their display info. + + limit : typing.Optional[int] + Number of messages per page (max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SearchChatResponse + Search results. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.search( + after="2026-04-13", + limit=20, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.search( + query=query, + in_=in_, + from_=from_, + with_=with_, + before=before, + after=after, + has=has, + chat_types=chat_types, + exclude_chat_ids=exclude_chat_ids, + exclude_user_ids=exclude_user_ids, + sort=sort, + expand=expand, + limit=limit, + cursor=cursor, + request_options=request_options, + ) + return _response.data + + async def resolve_link( + self, *, link: str, request_options: typing.Optional[RequestOptions] = None + ) -> ResolveLinkChatResponse: + """ + Parse a Roam chat deep link (e.g. `https://ro.am/r/#/d/...`) and return the + referenced message. + + When the caller has access to the referenced chat, the full message is + returned and `readable` is `true`. The `message` object is the same + shape as a `chat.history`/`chat.search` message — same fields, same + mention rendering. When the caller lacks access, the response still + includes the message key (`chatId`, `timestamp`, and `threadTimestamp` + if applicable) with `readable: false` and no message content — suitable + for rendering a reference without leaking content. + + Use [`/chat.link.create`](https://developer.ro.am/docs/api/chat-link-create) for the reverse + operation — minting a shareable Roam link from a message the caller can + already read. + + **Access:** Organization and Personal. + + **Required scope:** `chat:history` + + Parameters + ---------- + link : str + A Roam chat deep link URL that contains a message reference. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ResolveLinkChatResponse + Link resolved. When `readable` is false the caller lacks access to the chat; only the message key is returned. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.resolve_link( + link="https://ro.am/r/#/d/abc123xyz/c/757dfe66-37b4-4772-baa5-8c86ec68c176?ts=1765602474760032", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.resolve_link(link=link, request_options=request_options) + return _response.data + + async def create_link( + self, + *, + timestamp: int, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateLinkChatResponse: + """ + Create a shareable Roam link to a specific chat message. Opening the link + in Roam navigates to that message in its chat. + + Identify the chat with exactly one of `chatId`, `groupId`, or `userIds`, + and the message by its `timestamp` (Unix microseconds), as returned by + [`/chat.history`](https://developer.ro.am/docs/api/chat-history), [`/chat.post`](https://developer.ro.am/docs/api/chat-post), + or webhook message events. For a thread reply, also pass the thread root's + timestamp as `threadTimestamp` — without it the reply will not be found. + + The message must exist and be readable by the caller; otherwise no link is + returned (`404` if the message does not exist, `403` if the caller is not a + member of the chat). The link itself does not grant access: recipients can + only open it if they are members of the chat. + + Use [`/chat.link.resolve`](https://developer.ro.am/docs/api/chat-link-resolve) for the reverse + operation — turning a Roam chat link back into the referenced message. + + **Access:** Organization and Personal. In Personal mode, only chats the + authenticated user can access are allowed. + + **Required scope:** `chat:history` + + Parameters + ---------- + timestamp : int + The message's timestamp in Unix microseconds. + + chat_id : typing.Optional[str] + ID of the chat containing the message. Exactly one of `chatId`, `groupId`, or `userIds` is required. + + group_id : typing.Optional[str] + ID of a group whose channel chat contains the message. + + user_ids : typing.Optional[typing.Sequence[str]] + User ID(s) identifying the DM or group DM containing the message. + + thread_timestamp : typing.Optional[int] + The thread root's timestamp in Unix microseconds. Required when the message is a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateLinkChatResponse + Link created successfully. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.create_link( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + timestamp=1765602474760032, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_link( + timestamp=timestamp, + chat_id=chat_id, + group_id=group_id, + user_ids=user_ids, + thread_timestamp=thread_timestamp, + request_options=request_options, + ) + return _response.data + + async def unfurl( + self, + *, + chat_id: str, + message_timestamp: int, + unfurls: typing.Dict[str, UnfurlContent], + request_options: typing.Optional[RequestOptions] = None, + ) -> UnfurlChatResponse: + """ + Attach app-provided preview cards to links in an existing text message. + Every map key must be an exact URL currently present in the message and + must match one of the app's registered unfurl domains. Validation is + atomic: if any entry is invalid, no previews are changed. + + App previews replace Roam-generated previews for the same exact URL while + preserving unrelated previews. The server does not fetch any URL supplied + in this request. + + **Access:** Organization only (API Key or OAuth). Register unfurl domains on + the API client first — see [Unfurling links](https://developer.ro.am/docs/guides/unfurling-links). + Personal Access Tokens cannot register domains or call this endpoint. + + **Required scope:** `links:write` + + Parameters + ---------- + chat_id : str + + message_timestamp : int + Timestamp of a top-level or threaded message in Unix microseconds. + + unfurls : typing.Dict[str, UnfurlContent] + Preview content keyed by the exact URL from the message. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UnfurlChatResponse + Preview cards applied successfully. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient, UnfurlContent, UnfurlContentImage + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.chat.unfurl( + chat_id="8f3b9c2e-1a4d-4e7b-9c0a-2b6d1f5e3a7c", + message_timestamp=1748906400000000, + unfurls={ + "https://status.example.com/incidents/123": UnfurlContent( + title="Incident 123", + description="Investigating elevated errors", + site_name="PagerDuty", + favicon="https://status.example.com/favicon.png", + image=UnfurlContentImage( + url="https://status.example.com/incident.png", + type="image/png", + width=1200, + height=630, + alt="Incident status", + ), + ) + }, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.unfurl( + chat_id=chat_id, message_timestamp=message_timestamp, unfurls=unfurls, request_options=request_options + ) + return _response.data diff --git a/src/roamhq/chat/raw_client.py b/src/roamhq/chat/raw_client.py new file mode 100644 index 0000000..80664a5 --- /dev/null +++ b/src/roamhq/chat/raw_client.py @@ -0,0 +1,5633 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.datetime_utils import serialize_datetime +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..core.serialization import convert_and_respect_annotation_metadata +from ..errors.bad_request_error import BadRequestError +from ..errors.conflict_error import ConflictError +from ..errors.content_too_large_error import ContentTooLargeError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from ..types.sender import Sender +from ..types.unfurl_content import UnfurlContent +from .types.append_stream_chat_response import AppendStreamChatResponse +from .types.cancel_scheduled_chat_response import CancelScheduledChatResponse +from .types.create_link_chat_response import CreateLinkChatResponse +from .types.delete_chat_response import DeleteChatResponse +from .types.history_chat_response import HistoryChatResponse +from .types.list_chat_response import ListChatResponse +from .types.list_scheduled_chat_response import ListScheduledChatResponse +from .types.post_chat_request_blocks_item import PostChatRequestBlocksItem +from .types.post_chat_request_poll import PostChatRequestPoll +from .types.post_chat_response import PostChatResponse +from .types.post_ephemeral_chat_response import PostEphemeralChatResponse +from .types.resolve_link_chat_response import ResolveLinkChatResponse +from .types.search_chat_request_chat_types_item import SearchChatRequestChatTypesItem +from .types.search_chat_request_has_item import SearchChatRequestHasItem +from .types.search_chat_request_sort import SearchChatRequestSort +from .types.search_chat_response import SearchChatResponse +from .types.start_stream_chat_request_kind import StartStreamChatRequestKind +from .types.start_stream_chat_response import StartStreamChatResponse +from .types.stop_stream_chat_response import StopStreamChatResponse +from .types.typing_chat_request_sender import TypingChatRequestSender +from .types.unfurl_chat_response import UnfurlChatResponse +from .types.update_chat_request_blocks_item import UpdateChatRequestBlocksItem +from .types.update_chat_response import UpdateChatResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawChatClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListChatResponse]: + """ + List accessible chats — DMs, multi-DMs, group chats, all-hands "team + Roam" groups, and meeting chats. + + **Personal access tokens** are backed by the user's inbox: chats are + ordered by most recent activity and include `lastMessageTime`, + `isUnread`, `preview`, `isMuted`, and `isPinned`. Bot threads (where + the user has unread replies) are returned as separate rows keyed by + `threadTimestamp`. + + **Organization tokens** receive the chats the bot has access to, + ordered by chat creation time. Inbox-derived fields + (`lastMessageTime`, `isUnread`, `preview`, `isMuted`, `isPinned`) are + not populated, since bot addresses do not accumulate inbox state for + normal messages — those are delivered via webhooks. + + Timestamps are returned in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Required scope:** `chat:read` + + Pass `expand=addresses` to include an address sidecar for chat participants + and preview senders. See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + Parameters + ---------- + limit : typing.Optional[int] + Number of chats to return per response. Default 10, max 50. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving chat participants and preview sender IDs. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListChatResponse] + Chats retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "chat.list", + method="GET", + params={ + "limit": limit, + "cursor": cursor, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListChatResponse, + parse_obj_as( + type_=ListChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def post( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + thread_key: typing.Optional[str] = OMIT, + reply_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + poll: typing.Optional[PostChatRequestPoll] = OMIT, + sender: typing.Optional[Sender] = OMIT, + sync: typing.Optional[bool] = OMIT, + send_at: typing.Optional[dt.datetime] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PostChatResponse]: + """ + Send a message to a chat. Messages can be plain markdown text, rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts, or polls. + + **Destination (ONE of the following is required):** + - `chatId` - Post to an existing chat by its ID + - `groupId` - Post to a group chat + - `userIds` - Post to a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple destinations (e.g., both `chatId` and `groupId`) will return a 400 error. + + Mentions use Slack's token syntax with Slack's semantics: `<@ID>` mentions a principal (a user or bot, e.g. `<@7861a4c6-765a-495d-898d-fae3d8fbba2d>` — resolvable via [`user.info`](https://developer.ro.am/docs/api/user-info)), `` mentions a group or channel, notifying its members (resolvable via [`group.info`](https://developer.ro.am/docs/api/group-info)), and `` notifies everyone in the chat. + When rendered in the client, the tag will automatically be replaced with the human-readable display name (or "everyone" for ``). + On write, either token form is accepted for any mentionable ID; the legacy `<@all>` broadcast alias is accepted; and a Slack-style `|label` suffix (e.g. `<@7861a4c6-…|Rob>`, ``) is accepted and ignored — the mentioned entity's live display name is always used. Write-side acceptance is identical on every [API version](https://developer.ro.am/docs/guides/api-versioning). Messages read back always carry bare canonical tokens, and `` for the broadcast — on API versions from `2026-08-07`; clients pinned to older versions read the older grammar (`<@ID>` for every mention, `<@all>`). Slack forms Roam does not implement are reserved and stay literal text: `<#ID>` channel links, ``, and ``. + + **Custom sender (optional):** see the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + - `sender.name` / `sender.imageUrl` are per-message display overrides, stored on the message itself. + - `sender.id` authors the message as a configured bot persona (Roam Administration > Developer > edit your app > Add Bot Persona). Ids that don't match a configured persona are accepted and ignored — the message is authored by the app's root identity. Sending never creates or renames personas. + - **Personal access tokens**: Reject the `sender` field with 400. PATs always post as their personal bot. + + **Access:** Organization tokens can post to chats the bot is a member of, + and to **public groups** in the workspace without joining. Personal tokens + can post only where the owner is a member (`403` `not_in_chat` for an + unjoined public group). Full membership matrix: + [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Post to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Post to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Post to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Reply to a specific thread by providing the thread's timestamp. + If the timestamp doesn't correspond to an existing message, a 400 error is returned. + Mutually exclusive with `threadKey`. + + thread_key : typing.Optional[str] + A stable external identifier used to group related messages into a thread. + On the first use of a given `threadKey`, a new message is posted and the resulting + thread timestamp is stored. Subsequent messages with the same `threadKey` are + automatically threaded under the original message. + + This is useful for external integrations (e.g. PagerDuty, Grafana, Sentry) that + want to thread related messages using their own identifiers (such as `dedup_key`, + `fingerprint`, or `group_id`) without tracking Roam's internal thread timestamps. + + Mutually exclusive with `threadTimestamp`. When `threadKey` is provided, the + response is always synchronous (equivalent to `sync: true`). + + reply_timestamp : typing.Optional[int] + Reply directly to a specific message by its timestamp. Unlike + `threadTimestamp` (which threads a reply under a parent message in a + group), `replyTimestamp` is a direct reply used in DMs — which have no + threads — and within an existing channel thread. Text messages only: + not supported together with `blocks` or `poll`. + + text : typing.Optional[str] + Message text in GitHub-flavored markdown + + markdown : typing.Optional[bool] + Text is markdown by default. If set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Combines with + `text`/`items`; not with `blocks` or `poll`. + + blocks : typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + poll : typing.Optional[PostChatRequestPoll] + Create a poll message. Mutually exclusive with `text`, `items`, and `blocks`. + + sender : typing.Optional[Sender] + + sync : typing.Optional[bool] + If set, the post will be performed synchronously and its timestamp returned. Incompatible with `sendAt`. + + send_at : typing.Optional[dt.datetime] + Schedule the message for later delivery (RFC 3339). Requirements: + - Must be in the **future** and within **30 days** + - Must fall on a **15-minute UTC boundary** (`:00`, `:15`, `:30`, or `:45`; seconds and sub-seconds zero) + - Incompatible with `sync`, `poll`, `threadKey`, and `replyTimestamp` + + When `sendAt` is set, the response is `{chatId, scheduledMessageId, sendAt}` + instead of an immediate message `timestamp`. + + Scheduled messages can be listed via + [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list) and canceled via + [`/chat.scheduled.cancel`](https://developer.ro.am/docs/api/chat-scheduled-cancel) until they send. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[PostChatResponse] + Message posted or scheduled successfully. Immediate posts return + `chatId` (and `timestamp` when `sync` is set). Scheduled posts + (`sendAt`) return `chatId`, `scheduledMessageId`, and `sendAt`. + All success bodies include `"ok": true` — see + [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + """ + _response = self._client_wrapper.httpx_client.request( + "chat.post", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "threadTimestamp": thread_timestamp, + "threadKey": thread_key, + "replyTimestamp": reply_timestamp, + "text": text, + "markdown": markdown, + "items": items, + "assetIds": asset_ids, + "blocks": convert_and_respect_annotation_metadata( + object_=blocks, annotation=typing.Sequence[PostChatRequestBlocksItem], direction="write" + ), + "color": color, + "poll": convert_and_respect_annotation_metadata( + object_=poll, annotation=PostChatRequestPoll, direction="write" + ), + "sender": convert_and_respect_annotation_metadata(object_=sender, annotation=Sender, direction="write"), + "sync": sync, + "sendAt": send_at, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PostChatResponse, + parse_obj_as( + type_=PostChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def post_ephemeral( + self, + *, + chat_id: str, + user_id: str, + text: str, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PostEphemeralChatResponse]: + """ + Post an **ephemeral message** — visible to a single member of a chat, with an + "Only you can see this" header — without posting anything the other members can + see. This is the standard way for a bot to respond privately in a shared + channel (the Roam equivalent of Slack's `chat.postEphemeral`). + + The target `userId` must be a member of the chat (for channels: a member of the + backing group), otherwise the request fails with `user_not_in_chat`. + + `text` is always rendered as GitHub-flavored markdown. Mention markup + (`<@USER_ID>`) is **not** supported in ephemeral messages. Block Kit `blocks` + are not currently supported. + + **Delivery semantics — read before using:** + - **Desktop and web only.** Mobile clients do not display ephemeral messages, + and no mobile push notification is sent. A recipient who only uses Roam on + mobile will never see the message. + - **Best-effort, at-most-once.** The message is delivered in real time to the + recipient's connected clients, and to recently-active offline clients when + they reconnect. A recipient who has been offline for several days (or has + never signed in on that device) silently misses it. There are no retries + and no delivery receipt. + - **Transient.** The message is never stored server-side. It disappears when + the recipient restarts their app, and it never appears in + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) or [`/chat.search`](https://developer.ro.am/docs/api/chat-search). + - **Not addressable.** It cannot be edited or deleted: + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) and [`/chat.delete`](https://developer.ro.am/docs/api/chat-delete) + against its `(chatId, timestamp)` return `message_not_found`. + - **No webhooks.** Posting an ephemeral message never triggers a + [`chat.message`](https://developer.ro.am/docs/webhooks/chat-message) event, so it cannot leak to + org-wide webhook consumers. + + Do not use ephemeral messages for anything the recipient must durably receive — + use a DM ([`/chat.post`](https://developer.ro.am/docs/api/chat-post) with `userIds`) for that. + + **Custom sender (optional):** same semantics as [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — + `sender.name` / `sender.imageUrl` apply a per-message display override, and + `sender.id` authors the message as a configured bot persona (unknown ids + are accepted and ignored). Personal access tokens reject the `sender` + field. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Required scope:** `chat:send_message` or `chat:write` + + **Access:** Organization and Personal. The organization bot or + personal-token **owner** must be a member of the chat (`403` `not_in_chat` + otherwise) — unlike [`/chat.post`](https://developer.ro.am/docs/api/chat-post), there is no + public-group carveout. Personal tokens send as the user's personal bot + and reject the `sender` field. + + Parameters + ---------- + chat_id : str + The chat to post into. Use [`/chat.list`](https://developer.ro.am/docs/api/chat-list) or a `chat.message` webhook payload to obtain chat IDs. + + user_id : str + The user who should see the message. Must be a member of the chat. + + text : str + Message text in GitHub-flavored markdown (always rendered as + markdown; there is no plain-text mode). Maximum 8,000 bytes. + Mention markup is not supported. + + thread_timestamp : typing.Optional[int] + Show the ephemeral message inside an existing thread instead of the + main channel view. Channels only — returns 400 in DMs and Multi-DMs. + The value is not validated against an existing thread: pass a real + thread's timestamp, or the message is keyed under a thread view the + recipient can never open and is effectively never seen. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[PostEphemeralChatResponse] + Ephemeral message accepted for delivery. The `(chatId, timestamp)` pair is + the identity the recipient's client renders the message under; it is not + addressable by any other endpoint. All success bodies include `"ok": true` — + see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + """ + _response = self._client_wrapper.httpx_client.request( + "chat.postEphemeral", + method="POST", + json={ + "chatId": chat_id, + "userId": user_id, + "threadTimestamp": thread_timestamp, + "text": text, + "sender": convert_and_respect_annotation_metadata(object_=sender, annotation=Sender, direction="write"), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PostEphemeralChatResponse, + parse_obj_as( + type_=PostEphemeralChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_scheduled( + self, + *, + chat_id: typing.Optional[str] = None, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListScheduledChatResponse]: + """ + Lists pending messages scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s `sendAt` + that have not been sent yet. Results are ordered ascending by `sendAt` (soonest + first). Sent and canceled messages are not returned. + + Only messages scheduled by the calling credential's bot identity are listed: + organization tokens of the same app share the app's bot identity (and therefore + see each other's scheduled messages), while personal access tokens have a + per-person bot identity and see only their own. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Only return messages scheduled for this chat. + + after : typing.Optional[dt.datetime] + Only return messages scheduled to send after this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + before : typing.Optional[dt.datetime] + Only return messages scheduled to send before this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + limit : typing.Optional[int] + The number of scheduled messages to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListScheduledChatResponse] + OK + """ + _response = self._client_wrapper.httpx_client.request( + "chat.scheduled.list", + method="GET", + params={ + "chatId": chat_id, + "after": serialize_datetime(after) if after is not None else None, + "before": serialize_datetime(before) if before is not None else None, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListScheduledChatResponse, + parse_obj_as( + type_=ListScheduledChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def cancel_scheduled( + self, *, scheduled_message_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[CancelScheduledChatResponse]: + """ + Cancels a pending message scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s + `sendAt`, so it will never be delivered. Pending scheduled messages can be + discovered with [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list). + + Only the credential's bot identity that scheduled the message may cancel it. A + `scheduledMessageId` scheduled by a different identity — or one that never + existed — returns `scheduled_message_not_found`; the endpoint does not reveal + whether such an id exists. Canceling a message that has already been sent + returns `scheduled_message_already_sent`. + + Cancellation is best-effort once the scheduled send time arrives: delivery of a + due message begins in the seconds after its `sendAt` boundary, and a cancel + issued inside that window may return success while the message is still + delivered. Cancel ahead of the scheduled time to be safe. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + scheduled_message_id : str + The id returned by `/chat.post` when the message was scheduled. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CancelScheduledChatResponse] + Scheduled message canceled; it will not be delivered. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.scheduled.cancel", + method="POST", + json={ + "scheduledMessageId": scheduled_message_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CancelScheduledChatResponse, + parse_obj_as( + type_=CancelScheduledChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def start_stream( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + kind: typing.Optional[StartStreamChatRequestKind] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[StartStreamChatResponse]: + """ + Open a streaming message and post its first content. Streaming lets a bot + deliver a message incrementally — recipients see the text fill in live (with + a "typing…" indicator) instead of waiting for the full response. This is + useful for AI agents that produce text token-by-token. + + A stream has three steps, each its own request: + + 1. **[`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)** — open the stream and pick the destination. Returns a `streamId`. + 2. **[`/chat.appendStream`](https://developer.ro.am/docs/api/chat-append-stream)** — append chunks of text (call as many times as needed). + 3. **[`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream)** — finalize the stream into a single persisted message. + + Pass the `streamId` returned here to every subsequent `appendStream` and + `stopStream`. The sender, destination, and thread are fixed for the lifetime + of the stream. + + **Custom sender (optional):** same semantics as + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — `sender.name` / `sender.imageUrl` + apply a per-message display override to the finalized message, and + `sender.id` authors the stream as a configured bot persona (unknown ids + are accepted and ignored). The typing indicator shown while streaming uses + the override name when given, otherwise the persona's or app's configured + name. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. Organization tokens follow the + same public-group carveout as [`/chat.post`](https://developer.ro.am/docs/api/chat-post): the + bot may stream into a public group in its roam without joining. + Personal tokens can stream only where the owner is a member + (`403` `not_in_chat` for an unjoined public group) and reject the + `sender` field. + + **Required scope:** `chat:send_message` or `chat:write` + + ## Destination + + Provide exactly one of `chatId`, `groupId`, or `userIds`. If `text` is empty, + the destination is recorded but message creation is deferred until the first + non-empty `appendStream` or the `stopStream` call. + + ## Thinking streams + + Set `kind` to `thinking` to finalize the message as a thought-bubble; clients + show a "thinking…" indicator instead of "typing…". The default `kind` is `text`. + + ## Limits + + - Up to **10 concurrent streams per API client**. + - Only **one active stream per chat** at a time. + - Accumulated text may not exceed the regular message size limit. + + Parameters + ---------- + chat_id : typing.Optional[str] + Stream into an existing chat by ID (mutually exclusive with groupId/userIds). + + group_id : typing.Optional[str] + Stream into a group chat (mutually exclusive with chatId/userIds). + + user_ids : typing.Optional[typing.Sequence[str]] + Stream into a DM or Multi-DM with these users (mutually exclusive with chatId/groupId). + + kind : typing.Optional[StartStreamChatRequestKind] + Stream kind. `thinking` finalizes as a thought-bubble message. + + thread_timestamp : typing.Optional[int] + Optional thread to reply within. + + text : typing.Optional[str] + Optional initial text. May be empty to defer destination resolution until the first append/stop. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[StartStreamChatResponse] + Stream started. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.startStream", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "kind": kind, + "threadTimestamp": thread_timestamp, + "text": text, + "sender": convert_and_respect_annotation_metadata(object_=sender, annotation=Sender, direction="write"), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + StartStreamChatResponse, + parse_obj_as( + type_=StartStreamChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def append_stream( + self, + *, + stream_id: str, + text: str, + snapshot: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[AppendStreamChatResponse]: + """ + Append a chunk of text to an open stream (see + [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)). Each chunk is broadcast + to recipients as a delta, so the message appears to fill in live. Call as + many times as needed before [`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : str + Text chunk to append. Required and non-empty. + + snapshot : typing.Optional[bool] + If `true`, **replace** the accumulated text with `text` (and broadcast it + as a full snapshot) instead of appending. Useful when the client holds the + canonical current state — for example after rewriting prior output. The + message size limit is applied to the new `text` alone. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[AppendStreamChatResponse] + Chunk appended. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.appendStream", + method="POST", + json={ + "streamId": stream_id, + "text": text, + "snapshot": snapshot, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + AppendStreamChatResponse, + parse_obj_as( + type_=AppendStreamChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def stop_stream( + self, + *, + stream_id: str, + text: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[StopStreamChatResponse]: + """ + Finalize an open stream (see [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)) + into a single persisted chat message and return its timestamp. Optionally + include trailing `text` to append before finalizing. + + If the app never calls `stopStream` but has already streamed some text, the + server finalizes the buffered text into a message automatically. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : typing.Optional[str] + Optional trailing text appended before the message is finalized. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[StopStreamChatResponse] + Stream finalized and message persisted. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.stopStream", + method="POST", + json={ + "streamId": stream_id, + "text": text, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + StopStreamChatResponse, + parse_obj_as( + type_=StopStreamChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def update( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[UpdateChatResponse]: + """ + Edit a previously posted bot message. The updated message can contain plain markdown text or rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts. + + The bot must own the message being updated (matched by address ID). Personal access tokens always send as their bot persona and may only edit messages that personal bot posted. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to update. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + text : typing.Optional[str] + Updated markdown-formatted text content. Required unless `blocks` is provided. + Cannot be combined with `blocks`. + + markdown : typing.Optional[bool] + Text is markdown by default. If this is set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. Cannot be combined with `blocks`. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Cannot be combined with `blocks`. + + blocks : typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[UpdateChatResponse] + Message updated successfully + """ + _response = self._client_wrapper.httpx_client.request( + "chat.update", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + "text": text, + "markdown": markdown, + "items": items, + "assetIds": asset_ids, + "blocks": convert_and_respect_annotation_metadata( + object_=blocks, annotation=typing.Sequence[UpdateChatRequestBlocksItem], direction="write" + ), + "color": color, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UpdateChatResponse, + parse_obj_as( + type_=UpdateChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def delete( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[DeleteChatResponse]: + """ + Delete a previously posted bot message. The bot must own the message being deleted (matched by address ID). Personal access tokens always send as their bot persona and may only delete messages that personal bot posted. + + Deleting an already-deleted message is idempotent and returns success. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to delete. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[DeleteChatResponse] + Message deleted successfully + """ + _response = self._client_wrapper.httpx_client.request( + "chat.delete", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeleteChatResponse, + parse_obj_as( + type_=DeleteChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def typing( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[TypingChatRequestSender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[None]: + """ + Notify other chat participants that you are working on a response. + If they have the chat open, they will see "(Bot name) is typing...". + + The indicator lasts **6 seconds**. Re-send every **5 seconds** to keep + it visible while you work. Longer gaps will let it expire between pings. + + **Destination options (mutually exclusive):** + - `chatId` - Send to an existing chat by its ID + - `groupId` - Send to a group channel + - `userIds` - Send to a DM or Multi-DM with the specified users + + **Custom sender (optional):** pass `sender.id` to show the indicator as a + [configured bot persona](https://developer.ro.am/docs/guides/sender-profiles) — the persona's + configured name and avatar are used. Only `id` is accepted; `name` and + `imageUrl` are rejected on this endpoint. Selection is lookup-only: an id + that doesn't match a configured persona is accepted and ignored, and the + indicator shows the app's own identity (same for an omitted, empty, or `_` + id). Personal access tokens reject `sender` entirely. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Send to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Send to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Send to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Timestamp of the message being replied to. + + sender : typing.Optional[TypingChatRequestSender] + Optional configured bot persona to show the indicator as. Only + `id` is accepted — `name` and `imageUrl` are rejected on this + endpoint. Personal access tokens reject this field entirely. + See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "chat.typing", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "threadTimestamp": thread_timestamp, + "sender": convert_and_respect_annotation_metadata( + object_=sender, annotation=TypingChatRequestSender, direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def history( + self, + *, + chat_id: typing.Optional[str] = None, + group_id: typing.Optional[str] = None, + user_ids: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + thread_timestamp: typing.Optional[float] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[HistoryChatResponse]: + """ + List messages in a chat, filtered by date range (after/before). + + Messages with `contentType` of `text`, `voice`, or `poll` are returned. System messages and other content types are excluded. + + **Specify ONE of the following:** + - `chatId` - Fetch from an existing chat by its ID + - `groupId` - Fetch from a group chat + - `userIds` - Fetch from a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple (e.g., both `chatId` and `groupId`) will return a 400 error. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent messages are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Date-only inputs (`YYYY-MM-DD`) + are interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization tokens need to be a **member** of the chat + (`403` `not_in_chat` otherwise). Personal tokens can read any chat the + owner can, including public groups in their roam they have not joined. + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + chat_id : typing.Optional[str] + The chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + group_id : typing.Optional[str] + Group chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + user_ids : typing.Optional[typing.Union[str, typing.Sequence[str]]] + User IDs to fetch DM/Multi-DM messages with. Either chatId, groupId, or userIds must be specified. + + thread_timestamp : typing.Optional[float] + Read replies of the message with this timestamp. Specified in microseconds. + + after : typing.Optional[str] + The datetime to begin listing messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "no filter". + + before : typing.Optional[str] + The datetime until which to list messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "now". + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + limit : typing.Optional[int] + Number of messages to return (default 10, max 200). + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving the sender (`userId`) and mentioned IDs on + each message to their display info. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[HistoryChatResponse] + Messages retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "chat.history", + method="GET", + params={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "threadTimestamp": thread_timestamp, + "after": after, + "before": before, + "cursor": cursor, + "limit": limit, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + HistoryChatResponse, + parse_obj_as( + type_=HistoryChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def search( + self, + *, + query: typing.Optional[str] = OMIT, + in_: typing.Optional[typing.Sequence[str]] = OMIT, + from_: typing.Optional[typing.Sequence[str]] = OMIT, + with_: typing.Optional[typing.Sequence[str]] = OMIT, + before: typing.Optional[str] = OMIT, + after: typing.Optional[str] = OMIT, + has: typing.Optional[typing.Sequence[SearchChatRequestHasItem]] = OMIT, + chat_types: typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] = OMIT, + exclude_chat_ids: typing.Optional[typing.Sequence[str]] = OMIT, + exclude_user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + sort: typing.Optional[SearchChatRequestSort] = OMIT, + expand: typing.Optional[str] = OMIT, + limit: typing.Optional[int] = OMIT, + cursor: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SearchChatResponse]: + """ + Full-text search over the caller's accessible messages. Returns + full-fidelity messages — text, items, voice, polls, blocks, and + mentions — hydrated through the same pipeline as + [`/chat.history`](https://developer.ro.am/docs/api/chat-history). + + All fields are optional. With no parameters, the most recent messages + across all chat types (DMs, multi-DMs, group chats) are returned in + reverse chronological order. + + **Sort:** When omitted and `query` is empty, results are sorted + chronologically (newest first), since relevance scoring is meaningless + without search terms. Pass `sort: recent` to force chronological order + even with a text query. + + **Date filters:** `before` and `after` accept `YYYY-MM-DD`. Dates are + interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization and Personal. + + - **Personal tokens** search chats the owner can read, including public + groups in their roam they have not joined. + - **Organization tokens** search chats the bot is a **member** of, + plus unjoined **public** groups in the bot's roam (Slack + `search:read.public`). Private groups the bot is not in are excluded. + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) stays membership-only. + + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + query : typing.Optional[str] + Free-text search query. Empty matches all messages. + + in_ : typing.Optional[typing.Sequence[str]] + Group names to search within. + + from_ : typing.Optional[typing.Sequence[str]] + Filter to messages sent by these email addresses. + + with_ : typing.Optional[typing.Sequence[str]] + Filter to chats including these email addresses. + + before : typing.Optional[str] + Only include messages before this date (`YYYY-MM-DD`, caller's timezone). + + after : typing.Optional[str] + Only include messages on or after this date (`YYYY-MM-DD`, caller's timezone). + + has : typing.Optional[typing.Sequence[SearchChatRequestHasItem]] + Restrict to messages that contain a mention or an item. + + chat_types : typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] + Restrict to specific chat types. Defaults to all types + (channels, all-hands "team Roam" groups, and DMs). + + exclude_chat_ids : typing.Optional[typing.Sequence[str]] + Chat IDs to exclude from results. + + exclude_user_ids : typing.Optional[typing.Sequence[str]] + Sender user IDs to exclude from results. + + sort : typing.Optional[SearchChatRequestSort] + `relevant` (default) ranks by relevance to `query`; `recent` + sorts newest first. With an empty `query`, results are + sorted chronologically regardless. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — + include an `addresses` map resolving the sender (`userId`) and + mentioned IDs on each message to their display info. + + limit : typing.Optional[int] + Number of messages per page (max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SearchChatResponse] + Search results. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.search", + method="POST", + json={ + "query": query, + "in": in_, + "from": from_, + "with": with_, + "before": before, + "after": after, + "has": has, + "chatTypes": chat_types, + "excludeChatIds": exclude_chat_ids, + "excludeUserIds": exclude_user_ids, + "sort": sort, + "expand": expand, + "limit": limit, + "cursor": cursor, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SearchChatResponse, + parse_obj_as( + type_=SearchChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def resolve_link( + self, *, link: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ResolveLinkChatResponse]: + """ + Parse a Roam chat deep link (e.g. `https://ro.am/r/#/d/...`) and return the + referenced message. + + When the caller has access to the referenced chat, the full message is + returned and `readable` is `true`. The `message` object is the same + shape as a `chat.history`/`chat.search` message — same fields, same + mention rendering. When the caller lacks access, the response still + includes the message key (`chatId`, `timestamp`, and `threadTimestamp` + if applicable) with `readable: false` and no message content — suitable + for rendering a reference without leaking content. + + Use [`/chat.link.create`](https://developer.ro.am/docs/api/chat-link-create) for the reverse + operation — minting a shareable Roam link from a message the caller can + already read. + + **Access:** Organization and Personal. + + **Required scope:** `chat:history` + + Parameters + ---------- + link : str + A Roam chat deep link URL that contains a message reference. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ResolveLinkChatResponse] + Link resolved. When `readable` is false the caller lacks access to the chat; only the message key is returned. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.link.resolve", + method="POST", + json={ + "link": link, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ResolveLinkChatResponse, + parse_obj_as( + type_=ResolveLinkChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create_link( + self, + *, + timestamp: int, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[CreateLinkChatResponse]: + """ + Create a shareable Roam link to a specific chat message. Opening the link + in Roam navigates to that message in its chat. + + Identify the chat with exactly one of `chatId`, `groupId`, or `userIds`, + and the message by its `timestamp` (Unix microseconds), as returned by + [`/chat.history`](https://developer.ro.am/docs/api/chat-history), [`/chat.post`](https://developer.ro.am/docs/api/chat-post), + or webhook message events. For a thread reply, also pass the thread root's + timestamp as `threadTimestamp` — without it the reply will not be found. + + The message must exist and be readable by the caller; otherwise no link is + returned (`404` if the message does not exist, `403` if the caller is not a + member of the chat). The link itself does not grant access: recipients can + only open it if they are members of the chat. + + Use [`/chat.link.resolve`](https://developer.ro.am/docs/api/chat-link-resolve) for the reverse + operation — turning a Roam chat link back into the referenced message. + + **Access:** Organization and Personal. In Personal mode, only chats the + authenticated user can access are allowed. + + **Required scope:** `chat:history` + + Parameters + ---------- + timestamp : int + The message's timestamp in Unix microseconds. + + chat_id : typing.Optional[str] + ID of the chat containing the message. Exactly one of `chatId`, `groupId`, or `userIds` is required. + + group_id : typing.Optional[str] + ID of a group whose channel chat contains the message. + + user_ids : typing.Optional[typing.Sequence[str]] + User ID(s) identifying the DM or group DM containing the message. + + thread_timestamp : typing.Optional[int] + The thread root's timestamp in Unix microseconds. Required when the message is a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CreateLinkChatResponse] + Link created successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.link.create", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateLinkChatResponse, + parse_obj_as( + type_=CreateLinkChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def unfurl( + self, + *, + chat_id: str, + message_timestamp: int, + unfurls: typing.Dict[str, UnfurlContent], + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[UnfurlChatResponse]: + """ + Attach app-provided preview cards to links in an existing text message. + Every map key must be an exact URL currently present in the message and + must match one of the app's registered unfurl domains. Validation is + atomic: if any entry is invalid, no previews are changed. + + App previews replace Roam-generated previews for the same exact URL while + preserving unrelated previews. The server does not fetch any URL supplied + in this request. + + **Access:** Organization only (API Key or OAuth). Register unfurl domains on + the API client first — see [Unfurling links](https://developer.ro.am/docs/guides/unfurling-links). + Personal Access Tokens cannot register domains or call this endpoint. + + **Required scope:** `links:write` + + Parameters + ---------- + chat_id : str + + message_timestamp : int + Timestamp of a top-level or threaded message in Unix microseconds. + + unfurls : typing.Dict[str, UnfurlContent] + Preview content keyed by the exact URL from the message. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[UnfurlChatResponse] + Preview cards applied successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "chat.unfurl", + method="POST", + json={ + "chatId": chat_id, + "messageTimestamp": message_timestamp, + "unfurls": convert_and_respect_annotation_metadata( + object_=unfurls, annotation=typing.Dict[str, UnfurlContent], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UnfurlChatResponse, + parse_obj_as( + type_=UnfurlChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawChatClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, + *, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListChatResponse]: + """ + List accessible chats — DMs, multi-DMs, group chats, all-hands "team + Roam" groups, and meeting chats. + + **Personal access tokens** are backed by the user's inbox: chats are + ordered by most recent activity and include `lastMessageTime`, + `isUnread`, `preview`, `isMuted`, and `isPinned`. Bot threads (where + the user has unread replies) are returned as separate rows keyed by + `threadTimestamp`. + + **Organization tokens** receive the chats the bot has access to, + ordered by chat creation time. Inbox-derived fields + (`lastMessageTime`, `isUnread`, `preview`, `isMuted`, `isPinned`) are + not populated, since bot addresses do not accumulate inbox state for + normal messages — those are delivered via webhooks. + + Timestamps are returned in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Required scope:** `chat:read` + + Pass `expand=addresses` to include an address sidecar for chat participants + and preview senders. See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + Parameters + ---------- + limit : typing.Optional[int] + Number of chats to return per response. Default 10, max 50. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving chat participants and preview sender IDs. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListChatResponse] + Chats retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.list", + method="GET", + params={ + "limit": limit, + "cursor": cursor, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListChatResponse, + parse_obj_as( + type_=ListChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def post( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + thread_key: typing.Optional[str] = OMIT, + reply_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + poll: typing.Optional[PostChatRequestPoll] = OMIT, + sender: typing.Optional[Sender] = OMIT, + sync: typing.Optional[bool] = OMIT, + send_at: typing.Optional[dt.datetime] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PostChatResponse]: + """ + Send a message to a chat. Messages can be plain markdown text, rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts, or polls. + + **Destination (ONE of the following is required):** + - `chatId` - Post to an existing chat by its ID + - `groupId` - Post to a group chat + - `userIds` - Post to a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple destinations (e.g., both `chatId` and `groupId`) will return a 400 error. + + Mentions use Slack's token syntax with Slack's semantics: `<@ID>` mentions a principal (a user or bot, e.g. `<@7861a4c6-765a-495d-898d-fae3d8fbba2d>` — resolvable via [`user.info`](https://developer.ro.am/docs/api/user-info)), `` mentions a group or channel, notifying its members (resolvable via [`group.info`](https://developer.ro.am/docs/api/group-info)), and `` notifies everyone in the chat. + When rendered in the client, the tag will automatically be replaced with the human-readable display name (or "everyone" for ``). + On write, either token form is accepted for any mentionable ID; the legacy `<@all>` broadcast alias is accepted; and a Slack-style `|label` suffix (e.g. `<@7861a4c6-…|Rob>`, ``) is accepted and ignored — the mentioned entity's live display name is always used. Write-side acceptance is identical on every [API version](https://developer.ro.am/docs/guides/api-versioning). Messages read back always carry bare canonical tokens, and `` for the broadcast — on API versions from `2026-08-07`; clients pinned to older versions read the older grammar (`<@ID>` for every mention, `<@all>`). Slack forms Roam does not implement are reserved and stay literal text: `<#ID>` channel links, ``, and ``. + + **Custom sender (optional):** see the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + - `sender.name` / `sender.imageUrl` are per-message display overrides, stored on the message itself. + - `sender.id` authors the message as a configured bot persona (Roam Administration > Developer > edit your app > Add Bot Persona). Ids that don't match a configured persona are accepted and ignored — the message is authored by the app's root identity. Sending never creates or renames personas. + - **Personal access tokens**: Reject the `sender` field with 400. PATs always post as their personal bot. + + **Access:** Organization tokens can post to chats the bot is a member of, + and to **public groups** in the workspace without joining. Personal tokens + can post only where the owner is a member (`403` `not_in_chat` for an + unjoined public group). Full membership matrix: + [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Post to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Post to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Post to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Reply to a specific thread by providing the thread's timestamp. + If the timestamp doesn't correspond to an existing message, a 400 error is returned. + Mutually exclusive with `threadKey`. + + thread_key : typing.Optional[str] + A stable external identifier used to group related messages into a thread. + On the first use of a given `threadKey`, a new message is posted and the resulting + thread timestamp is stored. Subsequent messages with the same `threadKey` are + automatically threaded under the original message. + + This is useful for external integrations (e.g. PagerDuty, Grafana, Sentry) that + want to thread related messages using their own identifiers (such as `dedup_key`, + `fingerprint`, or `group_id`) without tracking Roam's internal thread timestamps. + + Mutually exclusive with `threadTimestamp`. When `threadKey` is provided, the + response is always synchronous (equivalent to `sync: true`). + + reply_timestamp : typing.Optional[int] + Reply directly to a specific message by its timestamp. Unlike + `threadTimestamp` (which threads a reply under a parent message in a + group), `replyTimestamp` is a direct reply used in DMs — which have no + threads — and within an existing channel thread. Text messages only: + not supported together with `blocks` or `poll`. + + text : typing.Optional[str] + Message text in GitHub-flavored markdown + + markdown : typing.Optional[bool] + Text is markdown by default. If set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Combines with + `text`/`items`; not with `blocks` or `poll`. + + blocks : typing.Optional[typing.Sequence[PostChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + poll : typing.Optional[PostChatRequestPoll] + Create a poll message. Mutually exclusive with `text`, `items`, and `blocks`. + + sender : typing.Optional[Sender] + + sync : typing.Optional[bool] + If set, the post will be performed synchronously and its timestamp returned. Incompatible with `sendAt`. + + send_at : typing.Optional[dt.datetime] + Schedule the message for later delivery (RFC 3339). Requirements: + - Must be in the **future** and within **30 days** + - Must fall on a **15-minute UTC boundary** (`:00`, `:15`, `:30`, or `:45`; seconds and sub-seconds zero) + - Incompatible with `sync`, `poll`, `threadKey`, and `replyTimestamp` + + When `sendAt` is set, the response is `{chatId, scheduledMessageId, sendAt}` + instead of an immediate message `timestamp`. + + Scheduled messages can be listed via + [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list) and canceled via + [`/chat.scheduled.cancel`](https://developer.ro.am/docs/api/chat-scheduled-cancel) until they send. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[PostChatResponse] + Message posted or scheduled successfully. Immediate posts return + `chatId` (and `timestamp` when `sync` is set). Scheduled posts + (`sendAt`) return `chatId`, `scheduledMessageId`, and `sendAt`. + All success bodies include `"ok": true` — see + [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.post", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "threadTimestamp": thread_timestamp, + "threadKey": thread_key, + "replyTimestamp": reply_timestamp, + "text": text, + "markdown": markdown, + "items": items, + "assetIds": asset_ids, + "blocks": convert_and_respect_annotation_metadata( + object_=blocks, annotation=typing.Sequence[PostChatRequestBlocksItem], direction="write" + ), + "color": color, + "poll": convert_and_respect_annotation_metadata( + object_=poll, annotation=PostChatRequestPoll, direction="write" + ), + "sender": convert_and_respect_annotation_metadata(object_=sender, annotation=Sender, direction="write"), + "sync": sync, + "sendAt": send_at, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PostChatResponse, + parse_obj_as( + type_=PostChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def post_ephemeral( + self, + *, + chat_id: str, + user_id: str, + text: str, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PostEphemeralChatResponse]: + """ + Post an **ephemeral message** — visible to a single member of a chat, with an + "Only you can see this" header — without posting anything the other members can + see. This is the standard way for a bot to respond privately in a shared + channel (the Roam equivalent of Slack's `chat.postEphemeral`). + + The target `userId` must be a member of the chat (for channels: a member of the + backing group), otherwise the request fails with `user_not_in_chat`. + + `text` is always rendered as GitHub-flavored markdown. Mention markup + (`<@USER_ID>`) is **not** supported in ephemeral messages. Block Kit `blocks` + are not currently supported. + + **Delivery semantics — read before using:** + - **Desktop and web only.** Mobile clients do not display ephemeral messages, + and no mobile push notification is sent. A recipient who only uses Roam on + mobile will never see the message. + - **Best-effort, at-most-once.** The message is delivered in real time to the + recipient's connected clients, and to recently-active offline clients when + they reconnect. A recipient who has been offline for several days (or has + never signed in on that device) silently misses it. There are no retries + and no delivery receipt. + - **Transient.** The message is never stored server-side. It disappears when + the recipient restarts their app, and it never appears in + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) or [`/chat.search`](https://developer.ro.am/docs/api/chat-search). + - **Not addressable.** It cannot be edited or deleted: + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) and [`/chat.delete`](https://developer.ro.am/docs/api/chat-delete) + against its `(chatId, timestamp)` return `message_not_found`. + - **No webhooks.** Posting an ephemeral message never triggers a + [`chat.message`](https://developer.ro.am/docs/webhooks/chat-message) event, so it cannot leak to + org-wide webhook consumers. + + Do not use ephemeral messages for anything the recipient must durably receive — + use a DM ([`/chat.post`](https://developer.ro.am/docs/api/chat-post) with `userIds`) for that. + + **Custom sender (optional):** same semantics as [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — + `sender.name` / `sender.imageUrl` apply a per-message display override, and + `sender.id` authors the message as a configured bot persona (unknown ids + are accepted and ignored). Personal access tokens reject the `sender` + field. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Required scope:** `chat:send_message` or `chat:write` + + **Access:** Organization and Personal. The organization bot or + personal-token **owner** must be a member of the chat (`403` `not_in_chat` + otherwise) — unlike [`/chat.post`](https://developer.ro.am/docs/api/chat-post), there is no + public-group carveout. Personal tokens send as the user's personal bot + and reject the `sender` field. + + Parameters + ---------- + chat_id : str + The chat to post into. Use [`/chat.list`](https://developer.ro.am/docs/api/chat-list) or a `chat.message` webhook payload to obtain chat IDs. + + user_id : str + The user who should see the message. Must be a member of the chat. + + text : str + Message text in GitHub-flavored markdown (always rendered as + markdown; there is no plain-text mode). Maximum 8,000 bytes. + Mention markup is not supported. + + thread_timestamp : typing.Optional[int] + Show the ephemeral message inside an existing thread instead of the + main channel view. Channels only — returns 400 in DMs and Multi-DMs. + The value is not validated against an existing thread: pass a real + thread's timestamp, or the message is keyed under a thread view the + recipient can never open and is effectively never seen. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[PostEphemeralChatResponse] + Ephemeral message accepted for delivery. The `(chatId, timestamp)` pair is + the identity the recipient's client renders the message under; it is not + addressable by any other endpoint. All success bodies include `"ok": true` — + see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.postEphemeral", + method="POST", + json={ + "chatId": chat_id, + "userId": user_id, + "threadTimestamp": thread_timestamp, + "text": text, + "sender": convert_and_respect_annotation_metadata(object_=sender, annotation=Sender, direction="write"), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PostEphemeralChatResponse, + parse_obj_as( + type_=PostEphemeralChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_scheduled( + self, + *, + chat_id: typing.Optional[str] = None, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListScheduledChatResponse]: + """ + Lists pending messages scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s `sendAt` + that have not been sent yet. Results are ordered ascending by `sendAt` (soonest + first). Sent and canceled messages are not returned. + + Only messages scheduled by the calling credential's bot identity are listed: + organization tokens of the same app share the app's bot identity (and therefore + see each other's scheduled messages), while personal access tokens have a + per-person bot identity and see only their own. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Only return messages scheduled for this chat. + + after : typing.Optional[dt.datetime] + Only return messages scheduled to send after this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + before : typing.Optional[dt.datetime] + Only return messages scheduled to send before this datetime + (YYYY-MM-DD or RFC-3339). Exclusive. + + limit : typing.Optional[int] + The number of scheduled messages to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListScheduledChatResponse] + OK + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.scheduled.list", + method="GET", + params={ + "chatId": chat_id, + "after": serialize_datetime(after) if after is not None else None, + "before": serialize_datetime(before) if before is not None else None, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListScheduledChatResponse, + parse_obj_as( + type_=ListScheduledChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def cancel_scheduled( + self, *, scheduled_message_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[CancelScheduledChatResponse]: + """ + Cancels a pending message scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s + `sendAt`, so it will never be delivered. Pending scheduled messages can be + discovered with [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list). + + Only the credential's bot identity that scheduled the message may cancel it. A + `scheduledMessageId` scheduled by a different identity — or one that never + existed — returns `scheduled_message_not_found`; the endpoint does not reveal + whether such an id exists. Canceling a message that has already been sent + returns `scheduled_message_already_sent`. + + Cancellation is best-effort once the scheduled send time arrives: delivery of a + due message begins in the seconds after its `sendAt` boundary, and a cancel + issued inside that window may return success while the message is still + delivered. Cancel ahead of the scheduled time to be safe. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + scheduled_message_id : str + The id returned by `/chat.post` when the message was scheduled. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CancelScheduledChatResponse] + Scheduled message canceled; it will not be delivered. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.scheduled.cancel", + method="POST", + json={ + "scheduledMessageId": scheduled_message_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CancelScheduledChatResponse, + parse_obj_as( + type_=CancelScheduledChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def start_stream( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + kind: typing.Optional[StartStreamChatRequestKind] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + sender: typing.Optional[Sender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[StartStreamChatResponse]: + """ + Open a streaming message and post its first content. Streaming lets a bot + deliver a message incrementally — recipients see the text fill in live (with + a "typing…" indicator) instead of waiting for the full response. This is + useful for AI agents that produce text token-by-token. + + A stream has three steps, each its own request: + + 1. **[`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)** — open the stream and pick the destination. Returns a `streamId`. + 2. **[`/chat.appendStream`](https://developer.ro.am/docs/api/chat-append-stream)** — append chunks of text (call as many times as needed). + 3. **[`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream)** — finalize the stream into a single persisted message. + + Pass the `streamId` returned here to every subsequent `appendStream` and + `stopStream`. The sender, destination, and thread are fixed for the lifetime + of the stream. + + **Custom sender (optional):** same semantics as + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — `sender.name` / `sender.imageUrl` + apply a per-message display override to the finalized message, and + `sender.id` authors the stream as a configured bot persona (unknown ids + are accepted and ignored). The typing indicator shown while streaming uses + the override name when given, otherwise the persona's or app's configured + name. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + **Access:** Organization and Personal. Organization tokens follow the + same public-group carveout as [`/chat.post`](https://developer.ro.am/docs/api/chat-post): the + bot may stream into a public group in its roam without joining. + Personal tokens can stream only where the owner is a member + (`403` `not_in_chat` for an unjoined public group) and reject the + `sender` field. + + **Required scope:** `chat:send_message` or `chat:write` + + ## Destination + + Provide exactly one of `chatId`, `groupId`, or `userIds`. If `text` is empty, + the destination is recorded but message creation is deferred until the first + non-empty `appendStream` or the `stopStream` call. + + ## Thinking streams + + Set `kind` to `thinking` to finalize the message as a thought-bubble; clients + show a "thinking…" indicator instead of "typing…". The default `kind` is `text`. + + ## Limits + + - Up to **10 concurrent streams per API client**. + - Only **one active stream per chat** at a time. + - Accumulated text may not exceed the regular message size limit. + + Parameters + ---------- + chat_id : typing.Optional[str] + Stream into an existing chat by ID (mutually exclusive with groupId/userIds). + + group_id : typing.Optional[str] + Stream into a group chat (mutually exclusive with chatId/userIds). + + user_ids : typing.Optional[typing.Sequence[str]] + Stream into a DM or Multi-DM with these users (mutually exclusive with chatId/groupId). + + kind : typing.Optional[StartStreamChatRequestKind] + Stream kind. `thinking` finalizes as a thought-bubble message. + + thread_timestamp : typing.Optional[int] + Optional thread to reply within. + + text : typing.Optional[str] + Optional initial text. May be empty to defer destination resolution until the first append/stop. + + sender : typing.Optional[Sender] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[StartStreamChatResponse] + Stream started. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.startStream", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "kind": kind, + "threadTimestamp": thread_timestamp, + "text": text, + "sender": convert_and_respect_annotation_metadata(object_=sender, annotation=Sender, direction="write"), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + StartStreamChatResponse, + parse_obj_as( + type_=StartStreamChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def append_stream( + self, + *, + stream_id: str, + text: str, + snapshot: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[AppendStreamChatResponse]: + """ + Append a chunk of text to an open stream (see + [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)). Each chunk is broadcast + to recipients as a delta, so the message appears to fill in live. Call as + many times as needed before [`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : str + Text chunk to append. Required and non-empty. + + snapshot : typing.Optional[bool] + If `true`, **replace** the accumulated text with `text` (and broadcast it + as a full snapshot) instead of appending. Useful when the client holds the + canonical current state — for example after rewriting prior output. The + message size limit is applied to the new `text` alone. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[AppendStreamChatResponse] + Chunk appended. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.appendStream", + method="POST", + json={ + "streamId": stream_id, + "text": text, + "snapshot": snapshot, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + AppendStreamChatResponse, + parse_obj_as( + type_=AppendStreamChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def stop_stream( + self, + *, + stream_id: str, + text: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[StopStreamChatResponse]: + """ + Finalize an open stream (see [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)) + into a single persisted chat message and return its timestamp. Optionally + include trailing `text` to append before finalizing. + + If the app never calls `stopStream` but has already streamed some text, the + server finalizes the buffered text into a message automatically. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + stream_id : str + The stream ID returned by chat.startStream. + + text : typing.Optional[str] + Optional trailing text appended before the message is finalized. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[StopStreamChatResponse] + Stream finalized and message persisted. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.stopStream", + method="POST", + json={ + "streamId": stream_id, + "text": text, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + StopStreamChatResponse, + parse_obj_as( + type_=StopStreamChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def update( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + text: typing.Optional[str] = OMIT, + markdown: typing.Optional[bool] = OMIT, + items: typing.Optional[typing.Sequence[str]] = OMIT, + asset_ids: typing.Optional[typing.Sequence[str]] = OMIT, + blocks: typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] = OMIT, + color: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[UpdateChatResponse]: + """ + Edit a previously posted bot message. The updated message can contain plain markdown text or rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts. + + The bot must own the message being updated (matched by address ID). Personal access tokens always send as their bot persona and may only edit messages that personal bot posted. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to update. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + text : typing.Optional[str] + Updated markdown-formatted text content. Required unless `blocks` is provided. + Cannot be combined with `blocks`. + + markdown : typing.Optional[bool] + Text is markdown by default. If this is set to false, markdown interpretation will be disabled. + + items : typing.Optional[typing.Sequence[str]] + Array of Item IDs to attach to this message. Cannot be combined with `blocks`. + + asset_ids : typing.Optional[typing.Sequence[str]] + Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) + to attach to this message. Each asset must be owned by your app + and fully uploaded (processed and ready). Cannot be combined with `blocks`. + + blocks : typing.Optional[typing.Sequence[UpdateChatRequestBlocksItem]] + Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. + Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + + color : typing.Optional[str] + Colored vertical strip on the side of the message. Only used with `blocks`. + Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[UpdateChatResponse] + Message updated successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.update", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + "text": text, + "markdown": markdown, + "items": items, + "assetIds": asset_ids, + "blocks": convert_and_respect_annotation_metadata( + object_=blocks, annotation=typing.Sequence[UpdateChatRequestBlocksItem], direction="write" + ), + "color": color, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UpdateChatResponse, + parse_obj_as( + type_=UpdateChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 413: + raise ContentTooLargeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def delete( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[DeleteChatResponse]: + """ + Delete a previously posted bot message. The bot must own the message being deleted (matched by address ID). Personal access tokens always send as their bot persona and may only delete messages that personal bot posted. + + Deleting an already-deleted message is idempotent and returns success. + + **Access:** Organization and Personal. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + ID of the chat containing the message. + + timestamp : int + Timestamp of the message to delete. + + thread_timestamp : typing.Optional[int] + Thread timestamp, if the message is in a thread. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[DeleteChatResponse] + Message deleted successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.delete", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeleteChatResponse, + parse_obj_as( + type_=DeleteChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def typing( + self, + *, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + sender: typing.Optional[TypingChatRequestSender] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[None]: + """ + Notify other chat participants that you are working on a response. + If they have the chat open, they will see "(Bot name) is typing...". + + The indicator lasts **6 seconds**. Re-send every **5 seconds** to keep + it visible while you work. Longer gaps will let it expire between pings. + + **Destination options (mutually exclusive):** + - `chatId` - Send to an existing chat by its ID + - `groupId` - Send to a group channel + - `userIds` - Send to a DM or Multi-DM with the specified users + + **Custom sender (optional):** pass `sender.id` to show the indicator as a + [configured bot persona](https://developer.ro.am/docs/guides/sender-profiles) — the persona's + configured name and avatar are used. Only `id` is accepted; `name` and + `imageUrl` are rejected on this endpoint. Selection is lookup-only: an id + that doesn't match a configured persona is accepted and ignored, and the + indicator shows the app's own identity (same for an omitted, empty, or `_` + id). Personal access tokens reject `sender` entirely. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : typing.Optional[str] + Send to an existing chat by ID (mutually exclusive with groupId/userIds) + + group_id : typing.Optional[str] + Send to a group channel (mutually exclusive with chatId/userIds) + + user_ids : typing.Optional[typing.Sequence[str]] + Send to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + + thread_timestamp : typing.Optional[int] + Timestamp of the message being replied to. + + sender : typing.Optional[TypingChatRequestSender] + Optional configured bot persona to show the indicator as. Only + `id` is accepted — `name` and `imageUrl` are rejected on this + endpoint. Personal access tokens reject this field entirely. + See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.typing", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "threadTimestamp": thread_timestamp, + "sender": convert_and_respect_annotation_metadata( + object_=sender, annotation=TypingChatRequestSender, direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def history( + self, + *, + chat_id: typing.Optional[str] = None, + group_id: typing.Optional[str] = None, + user_ids: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + thread_timestamp: typing.Optional[float] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[HistoryChatResponse]: + """ + List messages in a chat, filtered by date range (after/before). + + Messages with `contentType` of `text`, `voice`, or `poll` are returned. System messages and other content types are excluded. + + **Specify ONE of the following:** + - `chatId` - Fetch from an existing chat by its ID + - `groupId` - Fetch from a group chat + - `userIds` - Fetch from a DM or Multi-DM with the specified users + + You must specify exactly one destination. Specifying multiple (e.g., both `chatId` and `groupId`) will return a 400 error. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent messages are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Date-only inputs (`YYYY-MM-DD`) + are interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization tokens need to be a **member** of the chat + (`403` `not_in_chat` otherwise). Personal tokens can read any chat the + owner can, including public groups in their roam they have not joined. + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + chat_id : typing.Optional[str] + The chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + group_id : typing.Optional[str] + Group chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + + user_ids : typing.Optional[typing.Union[str, typing.Sequence[str]]] + User IDs to fetch DM/Multi-DM messages with. Either chatId, groupId, or userIds must be specified. + + thread_timestamp : typing.Optional[float] + Read replies of the message with this timestamp. Specified in microseconds. + + after : typing.Optional[str] + The datetime to begin listing messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "no filter". + + before : typing.Optional[str] + The datetime until which to list messages (YYYY-MM-DD or RFC-3339). + Date-only values are interpreted in the caller's timezone. + Sub-millisecond precision on datetimes is truncated. Defaults to + "now". + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + limit : typing.Optional[int] + Number of messages to return (default 10, max 200). + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — include an + `addresses` map resolving the sender (`userId`) and mentioned IDs on + each message to their display info. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[HistoryChatResponse] + Messages retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.history", + method="GET", + params={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "threadTimestamp": thread_timestamp, + "after": after, + "before": before, + "cursor": cursor, + "limit": limit, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + HistoryChatResponse, + parse_obj_as( + type_=HistoryChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def search( + self, + *, + query: typing.Optional[str] = OMIT, + in_: typing.Optional[typing.Sequence[str]] = OMIT, + from_: typing.Optional[typing.Sequence[str]] = OMIT, + with_: typing.Optional[typing.Sequence[str]] = OMIT, + before: typing.Optional[str] = OMIT, + after: typing.Optional[str] = OMIT, + has: typing.Optional[typing.Sequence[SearchChatRequestHasItem]] = OMIT, + chat_types: typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] = OMIT, + exclude_chat_ids: typing.Optional[typing.Sequence[str]] = OMIT, + exclude_user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + sort: typing.Optional[SearchChatRequestSort] = OMIT, + expand: typing.Optional[str] = OMIT, + limit: typing.Optional[int] = OMIT, + cursor: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SearchChatResponse]: + """ + Full-text search over the caller's accessible messages. Returns + full-fidelity messages — text, items, voice, polls, blocks, and + mentions — hydrated through the same pipeline as + [`/chat.history`](https://developer.ro.am/docs/api/chat-history). + + All fields are optional. With no parameters, the most recent messages + across all chat types (DMs, multi-DMs, group chats) are returned in + reverse chronological order. + + **Sort:** When omitted and `query` is empty, results are sorted + chronologically (newest first), since relevance scoring is meaningless + without search terms. Pass `sort: recent` to force chronological order + even with a text query. + + **Date filters:** `before` and `after` accept `YYYY-MM-DD`. Dates are + interpreted in the caller's timezone (see + [Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + + **Access:** Organization and Personal. + + - **Personal tokens** search chats the owner can read, including public + groups in their roam they have not joined. + - **Organization tokens** search chats the bot is a **member** of, + plus unjoined **public** groups in the bot's roam (Slack + `search:read.public`). Private groups the bot is not in are excluded. + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) stays membership-only. + + Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + + **Required scope:** `chat:history` + + Every returned sender includes `userId` plus `userType`. The ID resolves + through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. + + Parameters + ---------- + query : typing.Optional[str] + Free-text search query. Empty matches all messages. + + in_ : typing.Optional[typing.Sequence[str]] + Group names to search within. + + from_ : typing.Optional[typing.Sequence[str]] + Filter to messages sent by these email addresses. + + with_ : typing.Optional[typing.Sequence[str]] + Filter to chats including these email addresses. + + before : typing.Optional[str] + Only include messages before this date (`YYYY-MM-DD`, caller's timezone). + + after : typing.Optional[str] + Only include messages on or after this date (`YYYY-MM-DD`, caller's timezone). + + has : typing.Optional[typing.Sequence[SearchChatRequestHasItem]] + Restrict to messages that contain a mention or an item. + + chat_types : typing.Optional[typing.Sequence[SearchChatRequestChatTypesItem]] + Restrict to specific chat types. Defaults to all types + (channels, all-hands "team Roam" groups, and DMs). + + exclude_chat_ids : typing.Optional[typing.Sequence[str]] + Chat IDs to exclude from results. + + exclude_user_ids : typing.Optional[typing.Sequence[str]] + Sender user IDs to exclude from results. + + sort : typing.Optional[SearchChatRequestSort] + `relevant` (default) ranks by relevance to `query`; `recent` + sorts newest first. With an empty `query`, results are + sorted chronologically regardless. + + expand : typing.Optional[str] + Comma-separated fields to expand. Supported: `addresses` — + include an `addresses` map resolving the sender (`userId`) and + mentioned IDs on each message to their display info. + + limit : typing.Optional[int] + Number of messages per page (max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SearchChatResponse] + Search results. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.search", + method="POST", + json={ + "query": query, + "in": in_, + "from": from_, + "with": with_, + "before": before, + "after": after, + "has": has, + "chatTypes": chat_types, + "excludeChatIds": exclude_chat_ids, + "excludeUserIds": exclude_user_ids, + "sort": sort, + "expand": expand, + "limit": limit, + "cursor": cursor, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SearchChatResponse, + parse_obj_as( + type_=SearchChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def resolve_link( + self, *, link: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ResolveLinkChatResponse]: + """ + Parse a Roam chat deep link (e.g. `https://ro.am/r/#/d/...`) and return the + referenced message. + + When the caller has access to the referenced chat, the full message is + returned and `readable` is `true`. The `message` object is the same + shape as a `chat.history`/`chat.search` message — same fields, same + mention rendering. When the caller lacks access, the response still + includes the message key (`chatId`, `timestamp`, and `threadTimestamp` + if applicable) with `readable: false` and no message content — suitable + for rendering a reference without leaking content. + + Use [`/chat.link.create`](https://developer.ro.am/docs/api/chat-link-create) for the reverse + operation — minting a shareable Roam link from a message the caller can + already read. + + **Access:** Organization and Personal. + + **Required scope:** `chat:history` + + Parameters + ---------- + link : str + A Roam chat deep link URL that contains a message reference. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ResolveLinkChatResponse] + Link resolved. When `readable` is false the caller lacks access to the chat; only the message key is returned. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.link.resolve", + method="POST", + json={ + "link": link, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ResolveLinkChatResponse, + parse_obj_as( + type_=ResolveLinkChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_link( + self, + *, + timestamp: int, + chat_id: typing.Optional[str] = OMIT, + group_id: typing.Optional[str] = OMIT, + user_ids: typing.Optional[typing.Sequence[str]] = OMIT, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[CreateLinkChatResponse]: + """ + Create a shareable Roam link to a specific chat message. Opening the link + in Roam navigates to that message in its chat. + + Identify the chat with exactly one of `chatId`, `groupId`, or `userIds`, + and the message by its `timestamp` (Unix microseconds), as returned by + [`/chat.history`](https://developer.ro.am/docs/api/chat-history), [`/chat.post`](https://developer.ro.am/docs/api/chat-post), + or webhook message events. For a thread reply, also pass the thread root's + timestamp as `threadTimestamp` — without it the reply will not be found. + + The message must exist and be readable by the caller; otherwise no link is + returned (`404` if the message does not exist, `403` if the caller is not a + member of the chat). The link itself does not grant access: recipients can + only open it if they are members of the chat. + + Use [`/chat.link.resolve`](https://developer.ro.am/docs/api/chat-link-resolve) for the reverse + operation — turning a Roam chat link back into the referenced message. + + **Access:** Organization and Personal. In Personal mode, only chats the + authenticated user can access are allowed. + + **Required scope:** `chat:history` + + Parameters + ---------- + timestamp : int + The message's timestamp in Unix microseconds. + + chat_id : typing.Optional[str] + ID of the chat containing the message. Exactly one of `chatId`, `groupId`, or `userIds` is required. + + group_id : typing.Optional[str] + ID of a group whose channel chat contains the message. + + user_ids : typing.Optional[typing.Sequence[str]] + User ID(s) identifying the DM or group DM containing the message. + + thread_timestamp : typing.Optional[int] + The thread root's timestamp in Unix microseconds. Required when the message is a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CreateLinkChatResponse] + Link created successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.link.create", + method="POST", + json={ + "chatId": chat_id, + "groupId": group_id, + "userIds": user_ids, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateLinkChatResponse, + parse_obj_as( + type_=CreateLinkChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def unfurl( + self, + *, + chat_id: str, + message_timestamp: int, + unfurls: typing.Dict[str, UnfurlContent], + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[UnfurlChatResponse]: + """ + Attach app-provided preview cards to links in an existing text message. + Every map key must be an exact URL currently present in the message and + must match one of the app's registered unfurl domains. Validation is + atomic: if any entry is invalid, no previews are changed. + + App previews replace Roam-generated previews for the same exact URL while + preserving unrelated previews. The server does not fetch any URL supplied + in this request. + + **Access:** Organization only (API Key or OAuth). Register unfurl domains on + the API client first — see [Unfurling links](https://developer.ro.am/docs/guides/unfurling-links). + Personal Access Tokens cannot register domains or call this endpoint. + + **Required scope:** `links:write` + + Parameters + ---------- + chat_id : str + + message_timestamp : int + Timestamp of a top-level or threaded message in Unix microseconds. + + unfurls : typing.Dict[str, UnfurlContent] + Preview content keyed by the exact URL from the message. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[UnfurlChatResponse] + Preview cards applied successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "chat.unfurl", + method="POST", + json={ + "chatId": chat_id, + "messageTimestamp": message_timestamp, + "unfurls": convert_and_respect_annotation_metadata( + object_=unfurls, annotation=typing.Dict[str, UnfurlContent], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UnfurlChatResponse, + parse_obj_as( + type_=UnfurlChatResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 409: + raise ConflictError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/chat/types/__init__.py b/src/roamhq/chat/types/__init__.py new file mode 100644 index 0000000..f7e69b0 --- /dev/null +++ b/src/roamhq/chat/types/__init__.py @@ -0,0 +1,130 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .append_stream_chat_response import AppendStreamChatResponse + from .cancel_scheduled_chat_response import CancelScheduledChatResponse + from .create_link_chat_response import CreateLinkChatResponse + from .delete_chat_response import DeleteChatResponse + from .history_chat_response import HistoryChatResponse + from .list_chat_response import ListChatResponse + from .list_chat_response_chats_item import ListChatResponseChatsItem + from .list_chat_response_chats_item_preview import ListChatResponseChatsItemPreview + from .list_chat_response_chats_item_preview_content_type import ListChatResponseChatsItemPreviewContentType + from .list_chat_response_chats_item_preview_sender import ListChatResponseChatsItemPreviewSender + from .list_chat_response_chats_item_type import ListChatResponseChatsItemType + from .list_scheduled_chat_response import ListScheduledChatResponse + from .list_scheduled_chat_response_scheduled_messages_item import ListScheduledChatResponseScheduledMessagesItem + from .post_chat_request_blocks_item import PostChatRequestBlocksItem + from .post_chat_request_blocks_item_type import PostChatRequestBlocksItemType + from .post_chat_request_poll import PostChatRequestPoll + from .post_chat_response import PostChatResponse + from .post_ephemeral_chat_response import PostEphemeralChatResponse + from .resolve_link_chat_response import ResolveLinkChatResponse + from .search_chat_request_chat_types_item import SearchChatRequestChatTypesItem + from .search_chat_request_has_item import SearchChatRequestHasItem + from .search_chat_request_sort import SearchChatRequestSort + from .search_chat_response import SearchChatResponse + from .start_stream_chat_request_kind import StartStreamChatRequestKind + from .start_stream_chat_response import StartStreamChatResponse + from .stop_stream_chat_response import StopStreamChatResponse + from .typing_chat_request_sender import TypingChatRequestSender + from .unfurl_chat_response import UnfurlChatResponse + from .update_chat_request_blocks_item import UpdateChatRequestBlocksItem + from .update_chat_request_blocks_item_type import UpdateChatRequestBlocksItemType + from .update_chat_response import UpdateChatResponse +_dynamic_imports: typing.Dict[str, str] = { + "AppendStreamChatResponse": ".append_stream_chat_response", + "CancelScheduledChatResponse": ".cancel_scheduled_chat_response", + "CreateLinkChatResponse": ".create_link_chat_response", + "DeleteChatResponse": ".delete_chat_response", + "HistoryChatResponse": ".history_chat_response", + "ListChatResponse": ".list_chat_response", + "ListChatResponseChatsItem": ".list_chat_response_chats_item", + "ListChatResponseChatsItemPreview": ".list_chat_response_chats_item_preview", + "ListChatResponseChatsItemPreviewContentType": ".list_chat_response_chats_item_preview_content_type", + "ListChatResponseChatsItemPreviewSender": ".list_chat_response_chats_item_preview_sender", + "ListChatResponseChatsItemType": ".list_chat_response_chats_item_type", + "ListScheduledChatResponse": ".list_scheduled_chat_response", + "ListScheduledChatResponseScheduledMessagesItem": ".list_scheduled_chat_response_scheduled_messages_item", + "PostChatRequestBlocksItem": ".post_chat_request_blocks_item", + "PostChatRequestBlocksItemType": ".post_chat_request_blocks_item_type", + "PostChatRequestPoll": ".post_chat_request_poll", + "PostChatResponse": ".post_chat_response", + "PostEphemeralChatResponse": ".post_ephemeral_chat_response", + "ResolveLinkChatResponse": ".resolve_link_chat_response", + "SearchChatRequestChatTypesItem": ".search_chat_request_chat_types_item", + "SearchChatRequestHasItem": ".search_chat_request_has_item", + "SearchChatRequestSort": ".search_chat_request_sort", + "SearchChatResponse": ".search_chat_response", + "StartStreamChatRequestKind": ".start_stream_chat_request_kind", + "StartStreamChatResponse": ".start_stream_chat_response", + "StopStreamChatResponse": ".stop_stream_chat_response", + "TypingChatRequestSender": ".typing_chat_request_sender", + "UnfurlChatResponse": ".unfurl_chat_response", + "UpdateChatRequestBlocksItem": ".update_chat_request_blocks_item", + "UpdateChatRequestBlocksItemType": ".update_chat_request_blocks_item_type", + "UpdateChatResponse": ".update_chat_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "AppendStreamChatResponse", + "CancelScheduledChatResponse", + "CreateLinkChatResponse", + "DeleteChatResponse", + "HistoryChatResponse", + "ListChatResponse", + "ListChatResponseChatsItem", + "ListChatResponseChatsItemPreview", + "ListChatResponseChatsItemPreviewContentType", + "ListChatResponseChatsItemPreviewSender", + "ListChatResponseChatsItemType", + "ListScheduledChatResponse", + "ListScheduledChatResponseScheduledMessagesItem", + "PostChatRequestBlocksItem", + "PostChatRequestBlocksItemType", + "PostChatRequestPoll", + "PostChatResponse", + "PostEphemeralChatResponse", + "ResolveLinkChatResponse", + "SearchChatRequestChatTypesItem", + "SearchChatRequestHasItem", + "SearchChatRequestSort", + "SearchChatResponse", + "StartStreamChatRequestKind", + "StartStreamChatResponse", + "StopStreamChatResponse", + "TypingChatRequestSender", + "UnfurlChatResponse", + "UpdateChatRequestBlocksItem", + "UpdateChatRequestBlocksItemType", + "UpdateChatResponse", +] diff --git a/src/roamhq/chat/types/append_stream_chat_response.py b/src/roamhq/chat/types/append_stream_chat_response.py new file mode 100644 index 0000000..51637e3 --- /dev/null +++ b/src/roamhq/chat/types/append_stream_chat_response.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class AppendStreamChatResponse(UniversalBaseModel): + stream_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="streamId"), pydantic.Field(alias="streamId") + ] = None + chat_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="chatId"), pydantic.Field(alias="chatId") + ] = None + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field(alias="threadTimestamp", description="Thread timestamp if the stream is a thread reply."), + ] = None + """ + Thread timestamp if the stream is a thread reply. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/cancel_scheduled_chat_response.py b/src/roamhq/chat/types/cancel_scheduled_chat_response.py new file mode 100644 index 0000000..efc0081 --- /dev/null +++ b/src/roamhq/chat/types/cancel_scheduled_chat_response.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class CancelScheduledChatResponse(UniversalBaseModel): + scheduled_message_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="scheduledMessageId"), + pydantic.Field(alias="scheduledMessageId", description="The canceled scheduled message id."), + ] = None + """ + The canceled scheduled message id. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/create_link_chat_response.py b/src/roamhq/chat/types/create_link_chat_response.py new file mode 100644 index 0000000..1281840 --- /dev/null +++ b/src/roamhq/chat/types/create_link_chat_response.py @@ -0,0 +1,51 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class CreateLinkChatResponse(UniversalBaseModel): + link: str = pydantic.Field() + """ + Shareable Roam link that opens the message. + """ + + chat_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat containing the message."), + ] + """ + ID of the chat containing the message. + """ + + timestamp: int = pydantic.Field() + """ + The message's timestamp in Unix microseconds. + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", description="The thread root's timestamp. Omitted for top-level messages." + ), + ] = None + """ + The thread root's timestamp. Omitted for top-level messages. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/delete_chat_response.py b/src/roamhq/chat/types/delete_chat_response.py new file mode 100644 index 0000000..7952f7a --- /dev/null +++ b/src/roamhq/chat/types/delete_chat_response.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class DeleteChatResponse(UniversalBaseModel): + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat."), + ] = None + """ + ID of the chat. + """ + + timestamp: typing.Optional[int] = pydantic.Field(default=None) + """ + Timestamp of the deleted message. + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field(alias="threadTimestamp", description="Thread timestamp, if the message was in a thread."), + ] = None + """ + Thread timestamp, if the message was in a thread. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/history_chat_response.py b/src/roamhq/chat/types/history_chat_response.py new file mode 100644 index 0000000..1b7d270 --- /dev/null +++ b/src/roamhq/chat/types/history_chat_response.py @@ -0,0 +1,59 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.address import Address +from ...types.chat_message import ChatMessage + + +class HistoryChatResponse(UniversalBaseModel): + chat_id: typing_extensions.Annotated[ + str, FieldMetadata(alias="chatId"), pydantic.Field(alias="chatId", description="The chat ID") + ] + """ + The chat ID + """ + + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="A cursor to fetch the next page of results"), + ] = None + """ + A cursor to fetch the next page of results + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", + description="The thread timestamp being read, echoed from the request (present only when reading a thread).", + ), + ] = None + """ + The thread timestamp being read, echoed from the request (present only when reading a thread). + """ + + messages: typing.List[ChatMessage] + addresses: typing.Optional[typing.Dict[str, Address]] = pydantic.Field(default=None) + """ + Resolved address objects keyed by ID, for the senders and + mentioned entities in this response. Included only when + `expand=addresses` is requested. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/list_chat_response.py b/src/roamhq/chat/types/list_chat_response.py new file mode 100644 index 0000000..b6afc0e --- /dev/null +++ b/src/roamhq/chat/types/list_chat_response.py @@ -0,0 +1,38 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.address import Address +from .list_chat_response_chats_item import ListChatResponseChatsItem + + +class ListChatResponse(UniversalBaseModel): + chats: typing.Optional[typing.List[ListChatResponseChatsItem]] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Pagination cursor for fetching the next page of results."), + ] = None + """ + Pagination cursor for fetching the next page of results. + """ + + addresses: typing.Optional[typing.Dict[str, Address]] = pydantic.Field(default=None) + """ + Resolved addresses keyed by UUID. Included only with `expand=addresses`. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/list_chat_response_chats_item.py b/src/roamhq/chat/types/list_chat_response_chats_item.py new file mode 100644 index 0000000..3bb4bdc --- /dev/null +++ b/src/roamhq/chat/types/list_chat_response_chats_item.py @@ -0,0 +1,130 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_chat_response_chats_item_preview import ListChatResponseChatsItemPreview +from .list_chat_response_chats_item_type import ListChatResponseChatsItemType + + +class ListChatResponseChatsItem(UniversalBaseModel): + id: str = pydantic.Field() + """ + The Chat ID. + """ + + type: typing.Optional[ListChatResponseChatsItemType] = pydantic.Field(default=None) + """ + The kind of chat, using the same vocabulary as the + `chat.message` webhook `chatType` filter: `dm` for + direct and multi-person DMs, `group` for group chats + (including all-hands and meeting channels). Omitted + for chat kinds outside that vocabulary (e.g. meeting + chats). + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", + description="Unix-microsecond timestamp of the parent message\nwhen this row represents a thread (Personal tokens\nonly). Absent for top-level chat rows.", + ), + ] = None + """ + Unix-microsecond timestamp of the parent message + when this row represents a thread (Personal tokens + only). Absent for top-level chat rows. + """ + + name: str = pydantic.Field() + """ + Descriptive name for the chat: + + * Group chat — the group name + * DM — the name of the other party + * Multi-DM — comma-separated list of participant names + """ + + group_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="groupId"), + pydantic.Field( + alias="groupId", + description="The Group ID. Only present for group chats\n(including all-hands and meeting chats); absent for\nDMs and Multi-DMs.", + ), + ] = None + """ + The Group ID. Only present for group chats + (including all-hands and meeting chats); absent for + DMs and Multi-DMs. + """ + + created: dt.datetime = pydantic.Field() + """ + When the chat was created (RFC3339, caller's timezone). + """ + + last_message_time: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="lastMessageTime"), + pydantic.Field( + alias="lastMessageTime", + description="Time of the most recent activity. Personal access\nonly; absent on Organization responses.", + ), + ] = None + """ + Time of the most recent activity. Personal access + only; absent on Organization responses. + """ + + is_unread: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="isUnread"), + pydantic.Field(alias="isUnread", description="`true` if the chat has unread messages. Personal\naccess only."), + ] = None + """ + `true` if the chat has unread messages. Personal + access only. + """ + + preview: typing.Optional[ListChatResponseChatsItemPreview] = pydantic.Field(default=None) + """ + Preview of the most recent message. Personal access + only. + """ + + is_muted: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="isMuted"), + pydantic.Field(alias="isMuted", description="`true` if the user has muted the chat. Personal\naccess only."), + ] = None + """ + `true` if the user has muted the chat. Personal + access only. + """ + + is_pinned: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="isPinned"), + pydantic.Field(alias="isPinned", description="`true` if the user has pinned the chat. Personal\naccess only."), + ] = None + """ + `true` if the user has pinned the chat. Personal + access only. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/list_chat_response_chats_item_preview.py b/src/roamhq/chat/types/list_chat_response_chats_item_preview.py new file mode 100644 index 0000000..3612ae2 --- /dev/null +++ b/src/roamhq/chat/types/list_chat_response_chats_item_preview.py @@ -0,0 +1,61 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_chat_response_chats_item_preview_content_type import ListChatResponseChatsItemPreviewContentType +from .list_chat_response_chats_item_preview_sender import ListChatResponseChatsItemPreviewSender + + +class ListChatResponseChatsItemPreview(UniversalBaseModel): + """ + Preview of the most recent message. Personal access + only. + """ + + text: typing.Optional[str] = None + content_type: typing_extensions.Annotated[ + typing.Optional[ListChatResponseChatsItemPreviewContentType], + FieldMetadata(alias="contentType"), + pydantic.Field(alias="contentType"), + ] = None + sender_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="senderId"), + pydantic.Field( + alias="senderId", + description="User ID of the message sender. Present when the previewed message has a principal author; system previews (such as membership changes) and deleted messages omit it. When present, it resolves through `/user.info`.", + ), + ] = None + """ + User ID of the message sender. Present when the previewed message has a principal author; system previews (such as membership changes) and deleted messages omit it. When present, it resolves through `/user.info`. + """ + + sender: typing.Optional[ListChatResponseChatsItemPreviewSender] = pydantic.Field(default=None) + """ + Per-message sender display override the previewed + message was sent with. Present only when the + message carries one; `senderId` remains the + authoring identity. Omitted for deleted messages. + See the + [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + """ + + mentioned: typing.Optional[bool] = pydantic.Field(default=None) + """ + `true` if the previewed message mentions the user. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/list_chat_response_chats_item_preview_content_type.py b/src/roamhq/chat/types/list_chat_response_chats_item_preview_content_type.py new file mode 100644 index 0000000..d168f78 --- /dev/null +++ b/src/roamhq/chat/types/list_chat_response_chats_item_preview_content_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ListChatResponseChatsItemPreviewContentType = typing.Union[typing.Literal["text", "voice", "poll"], typing.Any] diff --git a/src/roamhq/chat/types/list_chat_response_chats_item_preview_sender.py b/src/roamhq/chat/types/list_chat_response_chats_item_preview_sender.py new file mode 100644 index 0000000..3502e41 --- /dev/null +++ b/src/roamhq/chat/types/list_chat_response_chats_item_preview_sender.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class ListChatResponseChatsItemPreviewSender(UniversalBaseModel): + """ + Per-message sender display override the previewed + message was sent with. Present only when the + message carries one; `senderId` remains the + authoring identity. Omitted for deleted messages. + See the + [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + """ + + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Display name override for the previewed message. + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="Avatar URL override for the previewed message."), + ] = None + """ + Avatar URL override for the previewed message. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/list_chat_response_chats_item_type.py b/src/roamhq/chat/types/list_chat_response_chats_item_type.py new file mode 100644 index 0000000..cc5095b --- /dev/null +++ b/src/roamhq/chat/types/list_chat_response_chats_item_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ListChatResponseChatsItemType = typing.Union[typing.Literal["dm", "group"], typing.Any] diff --git a/src/roamhq/chat/types/list_scheduled_chat_response.py b/src/roamhq/chat/types/list_scheduled_chat_response.py new file mode 100644 index 0000000..0bb3ca8 --- /dev/null +++ b/src/roamhq/chat/types/list_scheduled_chat_response.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_scheduled_chat_response_scheduled_messages_item import ListScheduledChatResponseScheduledMessagesItem + + +class ListScheduledChatResponse(UniversalBaseModel): + scheduled_messages: typing_extensions.Annotated[ + typing.Optional[typing.List[ListScheduledChatResponseScheduledMessagesItem]], + FieldMetadata(alias="scheduledMessages"), + pydantic.Field(alias="scheduledMessages"), + ] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Returned if there is a subsequent page of scheduled messages."), + ] = None + """ + Returned if there is a subsequent page of scheduled messages. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/list_scheduled_chat_response_scheduled_messages_item.py b/src/roamhq/chat/types/list_scheduled_chat_response_scheduled_messages_item.py new file mode 100644 index 0000000..cca00c2 --- /dev/null +++ b/src/roamhq/chat/types/list_scheduled_chat_response_scheduled_messages_item.py @@ -0,0 +1,78 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class ListScheduledChatResponseScheduledMessagesItem(UniversalBaseModel): + scheduled_message_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="scheduledMessageId"), + pydantic.Field( + alias="scheduledMessageId", + description="The id returned by `/chat.post` when the message was scheduled; pass to `/chat.scheduled.cancel`.", + ), + ] = None + """ + The id returned by `/chat.post` when the message was scheduled; pass to `/chat.scheduled.cancel`. + """ + + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="The chat the message will be posted to."), + ] = None + """ + The chat the message will be posted to. + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", + description="Thread the message will post into (present only when scheduled with `threadTimestamp`; unix micros, matching `/chat.post` and `/chat.history` message keys).", + ), + ] = None + """ + Thread the message will post into (present only when scheduled with `threadTimestamp`; unix micros, matching `/chat.post` and `/chat.history` message keys). + """ + + send_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="sendAt"), + pydantic.Field(alias="sendAt", description="When the message is scheduled to send (RFC 3339)."), + ] = None + """ + When the message is scheduled to send (RFC 3339). + """ + + created_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="createdAt"), + pydantic.Field(alias="createdAt", description="When the message was scheduled (RFC 3339)."), + ] = None + """ + When the message was scheduled (RFC 3339). + """ + + text: typing.Optional[str] = pydantic.Field(default=None) + """ + Preview snippet of the message text, truncated server-side. Empty for non-text content such as Block Kit messages. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/post_chat_request_blocks_item.py b/src/roamhq/chat/types/post_chat_request_blocks_item.py new file mode 100644 index 0000000..4b43099 --- /dev/null +++ b/src/roamhq/chat/types/post_chat_request_blocks_item.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .post_chat_request_blocks_item_type import PostChatRequestBlocksItemType + + +class PostChatRequestBlocksItem(UniversalBaseModel): + type: typing.Optional[PostChatRequestBlocksItemType] = pydantic.Field(default=None) + """ + The block type. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/post_chat_request_blocks_item_type.py b/src/roamhq/chat/types/post_chat_request_blocks_item_type.py new file mode 100644 index 0000000..80af5cc --- /dev/null +++ b/src/roamhq/chat/types/post_chat_request_blocks_item_type.py @@ -0,0 +1,9 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +PostChatRequestBlocksItemType = typing.Union[ + typing.Literal["header", "section", "context", "divider", "actions"], typing.Any +] diff --git a/src/roamhq/chat/types/post_chat_request_poll.py b/src/roamhq/chat/types/post_chat_request_poll.py new file mode 100644 index 0000000..02fcebd --- /dev/null +++ b/src/roamhq/chat/types/post_chat_request_poll.py @@ -0,0 +1,56 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class PostChatRequestPoll(UniversalBaseModel): + """ + Create a poll message. Mutually exclusive with `text`, `items`, and `blocks`. + """ + + question: str = pydantic.Field() + """ + The poll question (1–256 characters). + """ + + options: typing.List[str] = pydantic.Field() + """ + Poll answer options (at least 2, each 1–128 characters). + """ + + allow_multiple_answers: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="allowMultipleAnswers"), + pydantic.Field( + alias="allowMultipleAnswers", description="Whether voters can select multiple options. Defaults to false." + ), + ] = None + """ + Whether voters can select multiple options. Defaults to false. + """ + + closes_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="closesAt"), + pydantic.Field(alias="closesAt", description="Optional RFC-3339 datetime when the poll automatically closes."), + ] = None + """ + Optional RFC-3339 datetime when the poll automatically closes. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/post_chat_response.py b/src/roamhq/chat/types/post_chat_response.py new file mode 100644 index 0000000..0571df6 --- /dev/null +++ b/src/roamhq/chat/types/post_chat_response.py @@ -0,0 +1,69 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class PostChatResponse(UniversalBaseModel): + ok: typing.Optional[bool] = None + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat where the message was (or will be) posted"), + ] = None + """ + ID of the chat where the message was (or will be) posted + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field(alias="threadTimestamp", description="Thread timestamp if replying to a thread"), + ] = None + """ + Thread timestamp if replying to a thread + """ + + timestamp: typing.Optional[int] = pydantic.Field(default=None) + """ + Message timestamp (present if sync is set; omitted for scheduled posts) + """ + + scheduled_message_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="scheduledMessageId"), + pydantic.Field( + alias="scheduledMessageId", + description="ID of the scheduled message (only when `sendAt` was provided). Pass to `/chat.scheduled.cancel` to cancel, or find it later via `/chat.scheduled.list`.", + ), + ] = None + """ + ID of the scheduled message (only when `sendAt` was provided). Pass to `/chat.scheduled.cancel` to cancel, or find it later via `/chat.scheduled.list`. + """ + + send_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="sendAt"), + pydantic.Field( + alias="sendAt", description="Scheduled send time echoed from the request (only when `sendAt` was provided)" + ), + ] = None + """ + Scheduled send time echoed from the request (only when `sendAt` was provided) + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/post_ephemeral_chat_response.py b/src/roamhq/chat/types/post_ephemeral_chat_response.py new file mode 100644 index 0000000..0b70e9e --- /dev/null +++ b/src/roamhq/chat/types/post_ephemeral_chat_response.py @@ -0,0 +1,47 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class PostEphemeralChatResponse(UniversalBaseModel): + ok: typing.Optional[bool] = None + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat the message was delivered into"), + ] = None + """ + ID of the chat the message was delivered into + """ + + timestamp: typing.Optional[int] = pydantic.Field(default=None) + """ + Message timestamp in microseconds + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", description="Echoed thread timestamp when the message was posted into a thread" + ), + ] = None + """ + Echoed thread timestamp when the message was posted into a thread + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/resolve_link_chat_response.py b/src/roamhq/chat/types/resolve_link_chat_response.py new file mode 100644 index 0000000..94e795b --- /dev/null +++ b/src/roamhq/chat/types/resolve_link_chat_response.py @@ -0,0 +1,55 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.chat_message import ChatMessage + + +class ResolveLinkChatResponse(UniversalBaseModel): + chat_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat referenced by the link."), + ] + """ + ID of the chat referenced by the link. + """ + + timestamp: int = pydantic.Field() + """ + Timestamp of the referenced message (microseconds). + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", description="Thread timestamp if the referenced message is in a thread." + ), + ] = None + """ + Thread timestamp if the referenced message is in a thread. + """ + + readable: bool = pydantic.Field() + """ + `true` if the caller has access to the chat and `message` is populated. + `false` if the caller lacks access; no message content is returned. + """ + + message: typing.Optional[ChatMessage] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/search_chat_request_chat_types_item.py b/src/roamhq/chat/types/search_chat_request_chat_types_item.py new file mode 100644 index 0000000..2bfedcc --- /dev/null +++ b/src/roamhq/chat/types/search_chat_request_chat_types_item.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +SearchChatRequestChatTypesItem = typing.Union[typing.Literal["channel", "teamRoam", "address"], typing.Any] diff --git a/src/roamhq/chat/types/search_chat_request_has_item.py b/src/roamhq/chat/types/search_chat_request_has_item.py new file mode 100644 index 0000000..c31972b --- /dev/null +++ b/src/roamhq/chat/types/search_chat_request_has_item.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +SearchChatRequestHasItem = typing.Union[typing.Literal["mention", "item"], typing.Any] diff --git a/src/roamhq/chat/types/search_chat_request_sort.py b/src/roamhq/chat/types/search_chat_request_sort.py new file mode 100644 index 0000000..6f075db --- /dev/null +++ b/src/roamhq/chat/types/search_chat_request_sort.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +SearchChatRequestSort = typing.Union[typing.Literal["relevant", "recent"], typing.Any] diff --git a/src/roamhq/chat/types/search_chat_response.py b/src/roamhq/chat/types/search_chat_response.py new file mode 100644 index 0000000..0942cba --- /dev/null +++ b/src/roamhq/chat/types/search_chat_response.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.address import Address +from ...types.chat_message import ChatMessage + + +class SearchChatResponse(UniversalBaseModel): + messages: typing.List[ChatMessage] + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field( + alias="nextCursor", description="Cursor to fetch the next page. Absent when there are no more results." + ), + ] = None + """ + Cursor to fetch the next page. Absent when there are no more results. + """ + + addresses: typing.Optional[typing.Dict[str, Address]] = pydantic.Field(default=None) + """ + Resolved address objects keyed by ID, for the senders and + mentioned entities in this response. Included only when + `expand=addresses` is requested. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/start_stream_chat_request_kind.py b/src/roamhq/chat/types/start_stream_chat_request_kind.py new file mode 100644 index 0000000..0d393e6 --- /dev/null +++ b/src/roamhq/chat/types/start_stream_chat_request_kind.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +StartStreamChatRequestKind = typing.Union[typing.Literal["text", "thinking"], typing.Any] diff --git a/src/roamhq/chat/types/start_stream_chat_response.py b/src/roamhq/chat/types/start_stream_chat_response.py new file mode 100644 index 0000000..c5179be --- /dev/null +++ b/src/roamhq/chat/types/start_stream_chat_response.py @@ -0,0 +1,50 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class StartStreamChatResponse(UniversalBaseModel): + stream_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="streamId"), + pydantic.Field( + alias="streamId", description="Unique ID for this stream. Pass it to appendStream and stopStream." + ), + ] = None + """ + Unique ID for this stream. Pass it to appendStream and stopStream. + """ + + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the destination chat."), + ] = None + """ + ID of the destination chat. + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field(alias="threadTimestamp", description="Thread timestamp if the stream is a thread reply."), + ] = None + """ + Thread timestamp if the stream is a thread reply. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/stop_stream_chat_response.py b/src/roamhq/chat/types/stop_stream_chat_response.py new file mode 100644 index 0000000..cacb9c0 --- /dev/null +++ b/src/roamhq/chat/types/stop_stream_chat_response.py @@ -0,0 +1,47 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class StopStreamChatResponse(UniversalBaseModel): + stream_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="streamId"), pydantic.Field(alias="streamId") + ] = None + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat where the message was posted."), + ] = None + """ + ID of the chat where the message was posted. + """ + + timestamp: typing.Optional[int] = pydantic.Field(default=None) + """ + Timestamp of the finalized message (microseconds since epoch). + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field(alias="threadTimestamp", description="Thread timestamp if the stream was a thread reply."), + ] = None + """ + Thread timestamp if the stream was a thread reply. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/typing_chat_request_sender.py b/src/roamhq/chat/types/typing_chat_request_sender.py new file mode 100644 index 0000000..11fb0f2 --- /dev/null +++ b/src/roamhq/chat/types/typing_chat_request_sender.py @@ -0,0 +1,33 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TypingChatRequestSender(UniversalBaseModel): + """ + Optional configured bot persona to show the indicator as. Only + `id` is accepted — `name` and `imageUrl` are rejected on this + endpoint. Personal access tokens reject this field entirely. + See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + """ + + id: typing.Optional[str] = pydantic.Field(default=None) + """ + Code of a configured bot persona (lookup-only; never + creates one). Ids that don't match a configured persona + are accepted and ignored. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/unfurl_chat_response.py b/src/roamhq/chat/types/unfurl_chat_response.py new file mode 100644 index 0000000..0753f11 --- /dev/null +++ b/src/roamhq/chat/types/unfurl_chat_response.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class UnfurlChatResponse(UniversalBaseModel): + ok: bool + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/update_chat_request_blocks_item.py b/src/roamhq/chat/types/update_chat_request_blocks_item.py new file mode 100644 index 0000000..c88d308 --- /dev/null +++ b/src/roamhq/chat/types/update_chat_request_blocks_item.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .update_chat_request_blocks_item_type import UpdateChatRequestBlocksItemType + + +class UpdateChatRequestBlocksItem(UniversalBaseModel): + type: typing.Optional[UpdateChatRequestBlocksItemType] = pydantic.Field(default=None) + """ + The block type. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/chat/types/update_chat_request_blocks_item_type.py b/src/roamhq/chat/types/update_chat_request_blocks_item_type.py new file mode 100644 index 0000000..614bbe8 --- /dev/null +++ b/src/roamhq/chat/types/update_chat_request_blocks_item_type.py @@ -0,0 +1,9 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +UpdateChatRequestBlocksItemType = typing.Union[ + typing.Literal["header", "section", "context", "divider", "actions"], typing.Any +] diff --git a/src/roamhq/chat/types/update_chat_response.py b/src/roamhq/chat/types/update_chat_response.py new file mode 100644 index 0000000..54b4884 --- /dev/null +++ b/src/roamhq/chat/types/update_chat_response.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class UpdateChatResponse(UniversalBaseModel): + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat."), + ] = None + """ + ID of the chat. + """ + + timestamp: typing.Optional[int] = pydantic.Field(default=None) + """ + Timestamp of the updated message. + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field(alias="threadTimestamp", description="Thread timestamp, if the message is in a thread."), + ] = None + """ + Thread timestamp, if the message is in a thread. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/client.py b/src/roamhq/client.py new file mode 100644 index 0000000..691744b --- /dev/null +++ b/src/roamhq/client.py @@ -0,0 +1,583 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import httpx +from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from .core.logging import LogConfig, Logger +from .environment import RoamClientEnvironment + +if typing.TYPE_CHECKING: + from .asset.client import AssetClient, AsyncAssetClient + from .calendar.client import AsyncCalendarClient, CalendarClient + from .chat.client import AsyncChatClient, ChatClient + from .conversation.client import AsyncConversationClient, ConversationClient + from .group.client import AsyncGroupClient, GroupClient + from .groups.client import AsyncGroupsClient, GroupsClient + from .item.client import AsyncItemClient, ItemClient + from .lobby.client import AsyncLobbyClient, LobbyClient + from .magicast.client import AsyncMagicastClient, MagicastClient + from .magicasts.client import AsyncMagicastsClient, MagicastsClient + from .meeting.client import AsyncMeetingClient, MeetingClient + from .meetings.client import AsyncMeetingsClient, MeetingsClient + from .reaction.client import AsyncReactionClient, ReactionClient + from .story.client import AsyncStoryClient, StoryClient + from .token.client import AsyncTokenClient, TokenClient + from .user.client import AsyncUserClient, UserClient + from .user_audit_log.client import AsyncUserAuditLogClient, UserAuditLogClient + from .users.client import AsyncUsersClient, UsersClient + from .webhook.client import AsyncWebhookClient, WebhookClient + + +class RoamClient: + """ + Use this class to access the different functions within the SDK. You can instantiate any number of clients with different configuration that will propagate to these functions. + + Parameters + ---------- + base_url : typing.Optional[str] + The base url to use for requests from the client. + + environment : RoamClientEnvironment + The environment to use for requests from the client. from .environment import RoamClientEnvironment + + + + Defaults to RoamClientEnvironment.DEFAULT + + + + roam_version : typing.Optional[str] + token : typing.Union[str, typing.Callable[[], str]] + headers : typing.Optional[typing.Dict[str, str]] + Additional headers to send with every request. + + timeout : typing.Optional[float] + The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. + + max_retries : typing.Optional[int] + The default maximum number of retries for failed requests. Defaults to 2. Per-request `max_retries` in `request_options` takes precedence over this value. + + stream_reconnection_enabled : typing.Optional[bool] + Whether to automatically reconnect on stream disconnection for resumable streaming endpoints. Defaults to True. Per-request `stream_reconnection_enabled` in `request_options` takes precedence over this value. + + max_stream_reconnection_attempts : typing.Optional[int] + The maximum number of reconnection attempts for resumable streaming endpoints. Defaults to no limit. Per-request `max_stream_reconnection_attempts` in `request_options` takes precedence over this value. + + follow_redirects : typing.Optional[bool] + Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. + + httpx_client : typing.Optional[httpx.Client] + The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + + logging : typing.Optional[typing.Union[LogConfig, Logger]] + Configure logging for the SDK. Accepts a LogConfig dict with 'level' (debug/info/warn/error), 'logger' (custom logger implementation), and 'silent' (boolean, defaults to True) fields. You can also pass a pre-configured Logger instance. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + """ + + def __init__( + self, + *, + base_url: typing.Optional[str] = None, + environment: RoamClientEnvironment = RoamClientEnvironment.DEFAULT, + roam_version: typing.Optional[str] = None, + token: typing.Union[str, typing.Callable[[], str]], + headers: typing.Optional[typing.Dict[str, str]] = None, + timeout: typing.Optional[float] = None, + max_retries: typing.Optional[int] = None, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.Client] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + ): + _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None + _defaulted_max_retries = max_retries if max_retries is not None else 2 + self._client_wrapper = SyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + roam_version=roam_version, + token=token, + headers=headers, + httpx_client=httpx_client + if httpx_client is not None + else httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.Client(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + max_retries=_defaulted_max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, + logging=logging, + ) + self._chat: typing.Optional[ChatClient] = None + self._reaction: typing.Optional[ReactionClient] = None + self._asset: typing.Optional[AssetClient] = None + self._item: typing.Optional[ItemClient] = None + self._story: typing.Optional[StoryClient] = None + self._user: typing.Optional[UserClient] = None + self._users: typing.Optional[UsersClient] = None + self._user_audit_log: typing.Optional[UserAuditLogClient] = None + self._conversation: typing.Optional[ConversationClient] = None + self._meeting: typing.Optional[MeetingClient] = None + self._meetings: typing.Optional[MeetingsClient] = None + self._calendar: typing.Optional[CalendarClient] = None + self._lobby: typing.Optional[LobbyClient] = None + self._magicast: typing.Optional[MagicastClient] = None + self._magicasts: typing.Optional[MagicastsClient] = None + self._group: typing.Optional[GroupClient] = None + self._groups: typing.Optional[GroupsClient] = None + self._token: typing.Optional[TokenClient] = None + self._webhook: typing.Optional[WebhookClient] = None + + @property + def chat(self): + if self._chat is None: + from .chat.client import ChatClient # noqa: E402 + + self._chat = ChatClient(client_wrapper=self._client_wrapper) + return self._chat + + @property + def reaction(self): + if self._reaction is None: + from .reaction.client import ReactionClient # noqa: E402 + + self._reaction = ReactionClient(client_wrapper=self._client_wrapper) + return self._reaction + + @property + def asset(self): + if self._asset is None: + from .asset.client import AssetClient # noqa: E402 + + self._asset = AssetClient(client_wrapper=self._client_wrapper) + return self._asset + + @property + def item(self): + if self._item is None: + from .item.client import ItemClient # noqa: E402 + + self._item = ItemClient(client_wrapper=self._client_wrapper) + return self._item + + @property + def story(self): + if self._story is None: + from .story.client import StoryClient # noqa: E402 + + self._story = StoryClient(client_wrapper=self._client_wrapper) + return self._story + + @property + def user(self): + if self._user is None: + from .user.client import UserClient # noqa: E402 + + self._user = UserClient(client_wrapper=self._client_wrapper) + return self._user + + @property + def users(self): + if self._users is None: + from .users.client import UsersClient # noqa: E402 + + self._users = UsersClient(client_wrapper=self._client_wrapper) + return self._users + + @property + def user_audit_log(self): + if self._user_audit_log is None: + from .user_audit_log.client import UserAuditLogClient # noqa: E402 + + self._user_audit_log = UserAuditLogClient(client_wrapper=self._client_wrapper) + return self._user_audit_log + + @property + def conversation(self): + if self._conversation is None: + from .conversation.client import ConversationClient # noqa: E402 + + self._conversation = ConversationClient(client_wrapper=self._client_wrapper) + return self._conversation + + @property + def meeting(self): + if self._meeting is None: + from .meeting.client import MeetingClient # noqa: E402 + + self._meeting = MeetingClient(client_wrapper=self._client_wrapper) + return self._meeting + + @property + def meetings(self): + if self._meetings is None: + from .meetings.client import MeetingsClient # noqa: E402 + + self._meetings = MeetingsClient(client_wrapper=self._client_wrapper) + return self._meetings + + @property + def calendar(self): + if self._calendar is None: + from .calendar.client import CalendarClient # noqa: E402 + + self._calendar = CalendarClient(client_wrapper=self._client_wrapper) + return self._calendar + + @property + def lobby(self): + if self._lobby is None: + from .lobby.client import LobbyClient # noqa: E402 + + self._lobby = LobbyClient(client_wrapper=self._client_wrapper) + return self._lobby + + @property + def magicast(self): + if self._magicast is None: + from .magicast.client import MagicastClient # noqa: E402 + + self._magicast = MagicastClient(client_wrapper=self._client_wrapper) + return self._magicast + + @property + def magicasts(self): + if self._magicasts is None: + from .magicasts.client import MagicastsClient # noqa: E402 + + self._magicasts = MagicastsClient(client_wrapper=self._client_wrapper) + return self._magicasts + + @property + def group(self): + if self._group is None: + from .group.client import GroupClient # noqa: E402 + + self._group = GroupClient(client_wrapper=self._client_wrapper) + return self._group + + @property + def groups(self): + if self._groups is None: + from .groups.client import GroupsClient # noqa: E402 + + self._groups = GroupsClient(client_wrapper=self._client_wrapper) + return self._groups + + @property + def token(self): + if self._token is None: + from .token.client import TokenClient # noqa: E402 + + self._token = TokenClient(client_wrapper=self._client_wrapper) + return self._token + + @property + def webhook(self): + if self._webhook is None: + from .webhook.client import WebhookClient # noqa: E402 + + self._webhook = WebhookClient(client_wrapper=self._client_wrapper) + return self._webhook + + +def _make_default_async_client( + timeout: typing.Optional[float], + follow_redirects: typing.Optional[bool], +) -> httpx.AsyncClient: + try: + import httpx_aiohttp # type: ignore[import-not-found] + except ImportError: + pass + else: + if follow_redirects is not None: + return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout, follow_redirects=follow_redirects) + return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout) + + if follow_redirects is not None: + return httpx.AsyncClient(timeout=timeout, follow_redirects=follow_redirects) + return httpx.AsyncClient(timeout=timeout) + + +class AsyncRoamClient: + """ + Use this class to access the different functions within the SDK. You can instantiate any number of clients with different configuration that will propagate to these functions. + + Parameters + ---------- + base_url : typing.Optional[str] + The base url to use for requests from the client. + + environment : RoamClientEnvironment + The environment to use for requests from the client. from .environment import RoamClientEnvironment + + + + Defaults to RoamClientEnvironment.DEFAULT + + + + roam_version : typing.Optional[str] + token : typing.Union[str, typing.Callable[[], str]] + headers : typing.Optional[typing.Dict[str, str]] + Additional headers to send with every request. + + async_token : typing.Optional[typing.Callable[[], typing.Awaitable[str]]] + An async callable that returns a bearer token. Use this when token acquisition involves async I/O (e.g., refreshing tokens via an async HTTP client). When provided, this is used instead of the synchronous token for async requests. + + timeout : typing.Optional[float] + The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. + + max_retries : typing.Optional[int] + The default maximum number of retries for failed requests. Defaults to 2. Per-request `max_retries` in `request_options` takes precedence over this value. + + stream_reconnection_enabled : typing.Optional[bool] + Whether to automatically reconnect on stream disconnection for resumable streaming endpoints. Defaults to True. Per-request `stream_reconnection_enabled` in `request_options` takes precedence over this value. + + max_stream_reconnection_attempts : typing.Optional[int] + The maximum number of reconnection attempts for resumable streaming endpoints. Defaults to no limit. Per-request `max_stream_reconnection_attempts` in `request_options` takes precedence over this value. + + follow_redirects : typing.Optional[bool] + Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. + + httpx_client : typing.Optional[httpx.AsyncClient] + The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + + logging : typing.Optional[typing.Union[LogConfig, Logger]] + Configure logging for the SDK. Accepts a LogConfig dict with 'level' (debug/info/warn/error), 'logger' (custom logger implementation), and 'silent' (boolean, defaults to True) fields. You can also pass a pre-configured Logger instance. + + Examples + -------- + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + """ + + def __init__( + self, + *, + base_url: typing.Optional[str] = None, + environment: RoamClientEnvironment = RoamClientEnvironment.DEFAULT, + roam_version: typing.Optional[str] = None, + token: typing.Union[str, typing.Callable[[], str]], + headers: typing.Optional[typing.Dict[str, str]] = None, + async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, + timeout: typing.Optional[float] = None, + max_retries: typing.Optional[int] = None, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.AsyncClient] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + ): + _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None + _defaulted_max_retries = max_retries if max_retries is not None else 2 + self._client_wrapper = AsyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + roam_version=roam_version, + token=token, + headers=headers, + async_token=async_token, + httpx_client=httpx_client + if httpx_client is not None + else _make_default_async_client(timeout=_defaulted_timeout, follow_redirects=follow_redirects), + timeout=_defaulted_timeout, + max_retries=_defaulted_max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, + logging=logging, + ) + self._chat: typing.Optional[AsyncChatClient] = None + self._reaction: typing.Optional[AsyncReactionClient] = None + self._asset: typing.Optional[AsyncAssetClient] = None + self._item: typing.Optional[AsyncItemClient] = None + self._story: typing.Optional[AsyncStoryClient] = None + self._user: typing.Optional[AsyncUserClient] = None + self._users: typing.Optional[AsyncUsersClient] = None + self._user_audit_log: typing.Optional[AsyncUserAuditLogClient] = None + self._conversation: typing.Optional[AsyncConversationClient] = None + self._meeting: typing.Optional[AsyncMeetingClient] = None + self._meetings: typing.Optional[AsyncMeetingsClient] = None + self._calendar: typing.Optional[AsyncCalendarClient] = None + self._lobby: typing.Optional[AsyncLobbyClient] = None + self._magicast: typing.Optional[AsyncMagicastClient] = None + self._magicasts: typing.Optional[AsyncMagicastsClient] = None + self._group: typing.Optional[AsyncGroupClient] = None + self._groups: typing.Optional[AsyncGroupsClient] = None + self._token: typing.Optional[AsyncTokenClient] = None + self._webhook: typing.Optional[AsyncWebhookClient] = None + + @property + def chat(self): + if self._chat is None: + from .chat.client import AsyncChatClient # noqa: E402 + + self._chat = AsyncChatClient(client_wrapper=self._client_wrapper) + return self._chat + + @property + def reaction(self): + if self._reaction is None: + from .reaction.client import AsyncReactionClient # noqa: E402 + + self._reaction = AsyncReactionClient(client_wrapper=self._client_wrapper) + return self._reaction + + @property + def asset(self): + if self._asset is None: + from .asset.client import AsyncAssetClient # noqa: E402 + + self._asset = AsyncAssetClient(client_wrapper=self._client_wrapper) + return self._asset + + @property + def item(self): + if self._item is None: + from .item.client import AsyncItemClient # noqa: E402 + + self._item = AsyncItemClient(client_wrapper=self._client_wrapper) + return self._item + + @property + def story(self): + if self._story is None: + from .story.client import AsyncStoryClient # noqa: E402 + + self._story = AsyncStoryClient(client_wrapper=self._client_wrapper) + return self._story + + @property + def user(self): + if self._user is None: + from .user.client import AsyncUserClient # noqa: E402 + + self._user = AsyncUserClient(client_wrapper=self._client_wrapper) + return self._user + + @property + def users(self): + if self._users is None: + from .users.client import AsyncUsersClient # noqa: E402 + + self._users = AsyncUsersClient(client_wrapper=self._client_wrapper) + return self._users + + @property + def user_audit_log(self): + if self._user_audit_log is None: + from .user_audit_log.client import AsyncUserAuditLogClient # noqa: E402 + + self._user_audit_log = AsyncUserAuditLogClient(client_wrapper=self._client_wrapper) + return self._user_audit_log + + @property + def conversation(self): + if self._conversation is None: + from .conversation.client import AsyncConversationClient # noqa: E402 + + self._conversation = AsyncConversationClient(client_wrapper=self._client_wrapper) + return self._conversation + + @property + def meeting(self): + if self._meeting is None: + from .meeting.client import AsyncMeetingClient # noqa: E402 + + self._meeting = AsyncMeetingClient(client_wrapper=self._client_wrapper) + return self._meeting + + @property + def meetings(self): + if self._meetings is None: + from .meetings.client import AsyncMeetingsClient # noqa: E402 + + self._meetings = AsyncMeetingsClient(client_wrapper=self._client_wrapper) + return self._meetings + + @property + def calendar(self): + if self._calendar is None: + from .calendar.client import AsyncCalendarClient # noqa: E402 + + self._calendar = AsyncCalendarClient(client_wrapper=self._client_wrapper) + return self._calendar + + @property + def lobby(self): + if self._lobby is None: + from .lobby.client import AsyncLobbyClient # noqa: E402 + + self._lobby = AsyncLobbyClient(client_wrapper=self._client_wrapper) + return self._lobby + + @property + def magicast(self): + if self._magicast is None: + from .magicast.client import AsyncMagicastClient # noqa: E402 + + self._magicast = AsyncMagicastClient(client_wrapper=self._client_wrapper) + return self._magicast + + @property + def magicasts(self): + if self._magicasts is None: + from .magicasts.client import AsyncMagicastsClient # noqa: E402 + + self._magicasts = AsyncMagicastsClient(client_wrapper=self._client_wrapper) + return self._magicasts + + @property + def group(self): + if self._group is None: + from .group.client import AsyncGroupClient # noqa: E402 + + self._group = AsyncGroupClient(client_wrapper=self._client_wrapper) + return self._group + + @property + def groups(self): + if self._groups is None: + from .groups.client import AsyncGroupsClient # noqa: E402 + + self._groups = AsyncGroupsClient(client_wrapper=self._client_wrapper) + return self._groups + + @property + def token(self): + if self._token is None: + from .token.client import AsyncTokenClient # noqa: E402 + + self._token = AsyncTokenClient(client_wrapper=self._client_wrapper) + return self._token + + @property + def webhook(self): + if self._webhook is None: + from .webhook.client import AsyncWebhookClient # noqa: E402 + + self._webhook = AsyncWebhookClient(client_wrapper=self._client_wrapper) + return self._webhook + + +def _get_base_url(*, base_url: typing.Optional[str] = None, environment: RoamClientEnvironment) -> str: + if base_url is not None: + return base_url + elif environment is not None: + return environment.value + else: + raise Exception("Please pass in either base_url or environment to construct the client") diff --git a/src/roamhq/conversation/__init__.py b/src/roamhq/conversation/__init__.py new file mode 100644 index 0000000..0362e00 --- /dev/null +++ b/src/roamhq/conversation/__init__.py @@ -0,0 +1,48 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + ListConversationResponse, + ListConversationResponseConversationsItem, + ListConversationResponseConversationsItemParticipantsItem, + ) +_dynamic_imports: typing.Dict[str, str] = { + "ListConversationResponse": ".types", + "ListConversationResponseConversationsItem": ".types", + "ListConversationResponseConversationsItemParticipantsItem": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "ListConversationResponse", + "ListConversationResponseConversationsItem", + "ListConversationResponseConversationsItemParticipantsItem", +] diff --git a/src/roamhq/conversation/client.py b/src/roamhq/conversation/client.py new file mode 100644 index 0000000..1f963c8 --- /dev/null +++ b/src/roamhq/conversation/client.py @@ -0,0 +1,181 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawConversationClient, RawConversationClient +from .types.list_conversation_response import ListConversationResponse + + +class ConversationClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawConversationClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawConversationClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawConversationClient + """ + return self._raw_client + + def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListConversationResponse: + """ + Lists conversations (meetings) that occurred in your Roam, with participant details. + + **Access:** + - **Organization with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)** + (or a grandfathered roam-wide API key): all conversations in the workspace. + - **Personal access tokens:** supported — returns only conversations the + token owner participated in (matched by confirmed email). + - **Organization without roam-wide meeting access** must use + [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead (`403`). + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Participant details require `user:read` scope. Email addresses require `user:read.email` scope. + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return conversations that started before this ISO-8601 timestamp. + + after : typing.Optional[dt.datetime] + Only return conversations that started after this ISO-8601 timestamp. + + ascending : typing.Optional[bool] + Sort results in ascending order by start time. Default is descending (newest first). + + limit : typing.Optional[int] + The number of conversations to return per response. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListConversationResponse + Conversations retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.conversation.list() + """ + _response = self._raw_client.list( + before=before, after=after, ascending=ascending, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + +class AsyncConversationClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawConversationClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawConversationClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawConversationClient + """ + return self._raw_client + + async def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListConversationResponse: + """ + Lists conversations (meetings) that occurred in your Roam, with participant details. + + **Access:** + - **Organization with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)** + (or a grandfathered roam-wide API key): all conversations in the workspace. + - **Personal access tokens:** supported — returns only conversations the + token owner participated in (matched by confirmed email). + - **Organization without roam-wide meeting access** must use + [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead (`403`). + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Participant details require `user:read` scope. Email addresses require `user:read.email` scope. + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return conversations that started before this ISO-8601 timestamp. + + after : typing.Optional[dt.datetime] + Only return conversations that started after this ISO-8601 timestamp. + + ascending : typing.Optional[bool] + Sort results in ascending order by start time. Default is descending (newest first). + + limit : typing.Optional[int] + The number of conversations to return per response. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListConversationResponse + Conversations retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.conversation.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + before=before, after=after, ascending=ascending, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data diff --git a/src/roamhq/conversation/raw_client.py b/src/roamhq/conversation/raw_client.py new file mode 100644 index 0000000..3005308 --- /dev/null +++ b/src/roamhq/conversation/raw_client.py @@ -0,0 +1,305 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.datetime_utils import serialize_datetime +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.list_conversation_response import ListConversationResponse +from pydantic import ValidationError + + +class RawConversationClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListConversationResponse]: + """ + Lists conversations (meetings) that occurred in your Roam, with participant details. + + **Access:** + - **Organization with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)** + (or a grandfathered roam-wide API key): all conversations in the workspace. + - **Personal access tokens:** supported — returns only conversations the + token owner participated in (matched by confirmed email). + - **Organization without roam-wide meeting access** must use + [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead (`403`). + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Participant details require `user:read` scope. Email addresses require `user:read.email` scope. + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return conversations that started before this ISO-8601 timestamp. + + after : typing.Optional[dt.datetime] + Only return conversations that started after this ISO-8601 timestamp. + + ascending : typing.Optional[bool] + Sort results in ascending order by start time. Default is descending (newest first). + + limit : typing.Optional[int] + The number of conversations to return per response. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListConversationResponse] + Conversations retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "conversation.list", + method="GET", + params={ + "before": serialize_datetime(before) if before is not None else None, + "after": serialize_datetime(after) if after is not None else None, + "ascending": ascending, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListConversationResponse, + parse_obj_as( + type_=ListConversationResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawConversationClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListConversationResponse]: + """ + Lists conversations (meetings) that occurred in your Roam, with participant details. + + **Access:** + - **Organization with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)** + (or a grandfathered roam-wide API key): all conversations in the workspace. + - **Personal access tokens:** supported — returns only conversations the + token owner participated in (matched by confirmed email). + - **Organization without roam-wide meeting access** must use + [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead (`403`). + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Participant details require `user:read` scope. Email addresses require `user:read.email` scope. + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return conversations that started before this ISO-8601 timestamp. + + after : typing.Optional[dt.datetime] + Only return conversations that started after this ISO-8601 timestamp. + + ascending : typing.Optional[bool] + Sort results in ascending order by start time. Default is descending (newest first). + + limit : typing.Optional[int] + The number of conversations to return per response. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListConversationResponse] + Conversations retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "conversation.list", + method="GET", + params={ + "before": serialize_datetime(before) if before is not None else None, + "after": serialize_datetime(after) if after is not None else None, + "ascending": ascending, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListConversationResponse, + parse_obj_as( + type_=ListConversationResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/conversation/types/__init__.py b/src/roamhq/conversation/types/__init__.py new file mode 100644 index 0000000..79b1a78 --- /dev/null +++ b/src/roamhq/conversation/types/__init__.py @@ -0,0 +1,48 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .list_conversation_response import ListConversationResponse + from .list_conversation_response_conversations_item import ListConversationResponseConversationsItem + from .list_conversation_response_conversations_item_participants_item import ( + ListConversationResponseConversationsItemParticipantsItem, + ) +_dynamic_imports: typing.Dict[str, str] = { + "ListConversationResponse": ".list_conversation_response", + "ListConversationResponseConversationsItem": ".list_conversation_response_conversations_item", + "ListConversationResponseConversationsItemParticipantsItem": ".list_conversation_response_conversations_item_participants_item", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "ListConversationResponse", + "ListConversationResponseConversationsItem", + "ListConversationResponseConversationsItemParticipantsItem", +] diff --git a/src/roamhq/conversation/types/list_conversation_response.py b/src/roamhq/conversation/types/list_conversation_response.py new file mode 100644 index 0000000..586378e --- /dev/null +++ b/src/roamhq/conversation/types/list_conversation_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_conversation_response_conversations_item import ListConversationResponseConversationsItem + + +class ListConversationResponse(UniversalBaseModel): + conversations: typing.Optional[typing.List[ListConversationResponseConversationsItem]] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Pagination cursor for fetching the next page of results"), + ] = None + """ + Pagination cursor for fetching the next page of results + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/conversation/types/list_conversation_response_conversations_item.py b/src/roamhq/conversation/types/list_conversation_response_conversations_item.py new file mode 100644 index 0000000..1e6927e --- /dev/null +++ b/src/roamhq/conversation/types/list_conversation_response_conversations_item.py @@ -0,0 +1,75 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_conversation_response_conversations_item_participants_item import ( + ListConversationResponseConversationsItemParticipantsItem, +) + + +class ListConversationResponseConversationsItem(UniversalBaseModel): + id: typing.Optional[str] = pydantic.Field(default=None) + """ + Unique identifier for the conversation (meeting GUID) + """ + + place: typing.Optional[str] = pydantic.Field(default=None) + """ + The place where the conversation occurred + """ + + room: typing.Optional[str] = pydantic.Field(default=None) + """ + The room name + """ + + room_type: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="roomType"), + pydantic.Field(alias="roomType", description="The type of room"), + ] = None + """ + The type of room + """ + + start: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + When the conversation started (ISO-8601) + """ + + end: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + When the conversation ended (ISO-8601) + """ + + participants: typing.Optional[typing.List[ListConversationResponseConversationsItemParticipantsItem]] = ( + pydantic.Field(default=None) + ) + """ + List of participants (requires `user:read` scope) + """ + + meeting_link_ids: typing_extensions.Annotated[ + typing.Optional[typing.List[str]], + FieldMetadata(alias="meetingLinkIds"), + pydantic.Field(alias="meetingLinkIds", description="IDs of meeting links associated with this conversation"), + ] = None + """ + IDs of meeting links associated with this conversation + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/conversation/types/list_conversation_response_conversations_item_participants_item.py b/src/roamhq/conversation/types/list_conversation_response_conversations_item_participants_item.py new file mode 100644 index 0000000..31363b0 --- /dev/null +++ b/src/roamhq/conversation/types/list_conversation_response_conversations_item_participants_item.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class ListConversationResponseConversationsItemParticipantsItem(UniversalBaseModel): + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Display name of the participant + """ + + email: typing.Optional[str] = pydantic.Field(default=None) + """ + Email address (requires `user:read.email` scope) + """ + + seconds: typing.Optional[float] = pydantic.Field(default=None) + """ + Duration the participant was in the conversation, in seconds + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/core/__init__.py b/src/roamhq/core/__init__.py new file mode 100644 index 0000000..bb0fe4f --- /dev/null +++ b/src/roamhq/core/__init__.py @@ -0,0 +1,134 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .api_error import ApiError + from .client_wrapper import AsyncClientWrapper, BaseClientWrapper, SyncClientWrapper + from .datetime_utils import Rfc2822DateTime, parse_rfc2822_datetime, serialize_datetime + from .file import File, convert_file_dict_to_httpx_tuples, with_content_type + from .http_client import AsyncHttpClient, HttpClient + from .http_response import AsyncHttpResponse, HttpResponse + from .jsonable_encoder import encode_path_param, jsonable_encoder + from .logging import ConsoleLogger, ILogger, LogConfig, LogLevel, Logger, create_logger + from .pagination import AsyncPager, SyncPager + from .parse_error import ParsingError + from .pydantic_utilities import ( + IS_PYDANTIC_V2, + UniversalBaseModel, + UniversalRootModel, + parse_obj_as, + universal_field_validator, + universal_root_validator, + update_forward_refs, + ) + from .query_encoder import encode_query + from .remove_none_from_dict import remove_none_from_dict + from .request_options import RequestOptions + from .serialization import FieldMetadata, convert_and_respect_annotation_metadata +_dynamic_imports: typing.Dict[str, str] = { + "ApiError": ".api_error", + "AsyncClientWrapper": ".client_wrapper", + "AsyncHttpClient": ".http_client", + "AsyncHttpResponse": ".http_response", + "AsyncPager": ".pagination", + "BaseClientWrapper": ".client_wrapper", + "ConsoleLogger": ".logging", + "FieldMetadata": ".serialization", + "File": ".file", + "HttpClient": ".http_client", + "HttpResponse": ".http_response", + "ILogger": ".logging", + "IS_PYDANTIC_V2": ".pydantic_utilities", + "LogConfig": ".logging", + "LogLevel": ".logging", + "Logger": ".logging", + "ParsingError": ".parse_error", + "RequestOptions": ".request_options", + "Rfc2822DateTime": ".datetime_utils", + "SyncClientWrapper": ".client_wrapper", + "SyncPager": ".pagination", + "UniversalBaseModel": ".pydantic_utilities", + "UniversalRootModel": ".pydantic_utilities", + "convert_and_respect_annotation_metadata": ".serialization", + "convert_file_dict_to_httpx_tuples": ".file", + "create_logger": ".logging", + "encode_path_param": ".jsonable_encoder", + "encode_query": ".query_encoder", + "jsonable_encoder": ".jsonable_encoder", + "parse_obj_as": ".pydantic_utilities", + "parse_rfc2822_datetime": ".datetime_utils", + "remove_none_from_dict": ".remove_none_from_dict", + "serialize_datetime": ".datetime_utils", + "universal_field_validator": ".pydantic_utilities", + "universal_root_validator": ".pydantic_utilities", + "update_forward_refs": ".pydantic_utilities", + "with_content_type": ".file", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "ApiError", + "AsyncClientWrapper", + "AsyncHttpClient", + "AsyncHttpResponse", + "AsyncPager", + "BaseClientWrapper", + "ConsoleLogger", + "FieldMetadata", + "File", + "HttpClient", + "HttpResponse", + "ILogger", + "IS_PYDANTIC_V2", + "LogConfig", + "LogLevel", + "Logger", + "ParsingError", + "RequestOptions", + "Rfc2822DateTime", + "SyncClientWrapper", + "SyncPager", + "UniversalBaseModel", + "UniversalRootModel", + "convert_and_respect_annotation_metadata", + "convert_file_dict_to_httpx_tuples", + "create_logger", + "encode_path_param", + "encode_query", + "jsonable_encoder", + "parse_obj_as", + "parse_rfc2822_datetime", + "remove_none_from_dict", + "serialize_datetime", + "universal_field_validator", + "universal_root_validator", + "update_forward_refs", + "with_content_type", +] diff --git a/src/roamhq/core/api_error.py b/src/roamhq/core/api_error.py new file mode 100644 index 0000000..8b207e3 --- /dev/null +++ b/src/roamhq/core/api_error.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +class ApiError(Exception): + headers: Optional[Dict[str, str]] + status_code: Optional[int] + body: Any + + def __init__( + self, + *, + headers: Optional[Dict[str, str]] = None, + status_code: Optional[int] = None, + body: Any = None, + ) -> None: + self.headers = headers + self.status_code = status_code + self.body = body + + def __str__(self) -> str: + return f"headers: {self.headers}, status_code: {self.status_code}, body: {self.body}" diff --git a/src/roamhq/core/client_wrapper.py b/src/roamhq/core/client_wrapper.py new file mode 100644 index 0000000..ed30d24 --- /dev/null +++ b/src/roamhq/core/client_wrapper.py @@ -0,0 +1,154 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import httpx +from .http_client import AsyncHttpClient, HttpClient +from .logging import LogConfig, Logger + + +class BaseClientWrapper: + def __init__( + self, + *, + roam_version: typing.Optional[str] = None, + token: typing.Union[str, typing.Callable[[], str]], + headers: typing.Optional[typing.Dict[str, str]] = None, + base_url: str, + timeout: typing.Optional[float] = None, + max_retries: int = 2, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + ): + self._roam_version = roam_version + self._token = token + self._headers = headers + self._base_url = base_url + self._timeout = timeout + self._max_retries = max_retries + self._stream_reconnection_enabled = stream_reconnection_enabled + self._max_stream_reconnection_attempts = max_stream_reconnection_attempts + self._logging = logging + + def get_headers(self) -> typing.Dict[str, str]: + import platform + + headers: typing.Dict[str, str] = { + "X-Fern-Language": "Python", + "X-Fern-Runtime": f"python/{platform.python_version()}", + "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", + **(self.get_custom_headers() or {}), + } + if self._roam_version is not None: + headers["Roam-Version"] = self._roam_version + headers["Authorization"] = f"Bearer {self._get_token()}" + return headers + + def _get_token(self) -> str: + if isinstance(self._token, str): + return self._token + else: + return self._token() + + def get_custom_headers(self) -> typing.Optional[typing.Dict[str, str]]: + return self._headers + + def get_base_url(self) -> str: + return self._base_url + + def get_timeout(self) -> typing.Optional[float]: + return self._timeout + + def get_max_retries(self) -> int: + return self._max_retries + + def get_stream_reconnection_enabled(self) -> bool: + return self._stream_reconnection_enabled if self._stream_reconnection_enabled is not None else True + + def get_max_stream_reconnection_attempts(self) -> typing.Optional[int]: + return self._max_stream_reconnection_attempts + + +class SyncClientWrapper(BaseClientWrapper): + def __init__( + self, + *, + roam_version: typing.Optional[str] = None, + token: typing.Union[str, typing.Callable[[], str]], + headers: typing.Optional[typing.Dict[str, str]] = None, + base_url: str, + timeout: typing.Optional[float] = None, + max_retries: int = 2, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + httpx_client: httpx.Client, + ): + super().__init__( + roam_version=roam_version, + token=token, + headers=headers, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, + logging=logging, + ) + self.httpx_client = HttpClient( + httpx_client=httpx_client, + base_headers=self.get_headers, + base_timeout=self.get_timeout, + base_url=self.get_base_url, + base_max_retries=self.get_max_retries(), + logging_config=self._logging, + ) + + +class AsyncClientWrapper(BaseClientWrapper): + def __init__( + self, + *, + roam_version: typing.Optional[str] = None, + token: typing.Union[str, typing.Callable[[], str]], + headers: typing.Optional[typing.Dict[str, str]] = None, + base_url: str, + timeout: typing.Optional[float] = None, + max_retries: int = 2, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, + httpx_client: httpx.AsyncClient, + ): + super().__init__( + roam_version=roam_version, + token=token, + headers=headers, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, + logging=logging, + ) + self._async_token = async_token + self.httpx_client = AsyncHttpClient( + httpx_client=httpx_client, + base_headers=self.get_headers, + base_timeout=self.get_timeout, + base_url=self.get_base_url, + base_max_retries=self.get_max_retries(), + async_base_headers=self.async_get_headers, + logging_config=self._logging, + ) + + async def async_get_headers(self) -> typing.Dict[str, str]: + headers = self.get_headers() + if self._async_token is not None: + token = await self._async_token() + headers["Authorization"] = f"Bearer {token}" + return headers diff --git a/src/roamhq/core/datetime_utils.py b/src/roamhq/core/datetime_utils.py new file mode 100644 index 0000000..8589d96 --- /dev/null +++ b/src/roamhq/core/datetime_utils.py @@ -0,0 +1,72 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +from email.utils import parsedate_to_datetime +from typing import Any + +import pydantic + +IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + + +def parse_rfc2822_datetime(v: Any) -> dt.datetime: + """ + Parse an RFC 2822 datetime string (e.g., "Wed, 02 Oct 2002 13:00:00 GMT") + into a datetime object. If the value is already a datetime, return it as-is. + Falls back to ISO 8601 parsing if RFC 2822 parsing fails. + """ + if isinstance(v, dt.datetime): + return v + if isinstance(v, str): + try: + return parsedate_to_datetime(v) + except Exception: + pass + # Fallback to ISO 8601 parsing + return dt.datetime.fromisoformat(v.replace("Z", "+00:00")) + raise ValueError(f"Expected str or datetime, got {type(v)}") + + +class Rfc2822DateTime(dt.datetime): + """A datetime subclass that parses RFC 2822 date strings. + + On Pydantic V1, uses __get_validators__ for pre-validation. + On Pydantic V2, uses __get_pydantic_core_schema__ for BeforeValidator-style parsing. + """ + + @classmethod + def __get_validators__(cls): # type: ignore[no-untyped-def] + yield parse_rfc2822_datetime + + @classmethod + def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> Any: # type: ignore[override] + from pydantic_core import core_schema + + return core_schema.no_info_before_validator_function(parse_rfc2822_datetime, core_schema.datetime_schema()) + + +def serialize_datetime(v: dt.datetime) -> str: + """ + Serialize a datetime including timezone info. + + Uses the timezone info provided if present, otherwise uses the current runtime's timezone info. + + UTC datetimes end in "Z" while all other timezones are represented as offset from UTC, e.g. +05:00. + """ + + def _serialize_zoned_datetime(v: dt.datetime) -> str: + if v.tzinfo is not None and v.tzinfo.tzname(None) == dt.timezone.utc.tzname(None): + # UTC is a special case where we use "Z" at the end instead of "+00:00" + return v.isoformat().replace("+00:00", "Z") + else: + # Delegate to the typical +/- offset format + return v.isoformat() + + if v.tzinfo is not None: + return _serialize_zoned_datetime(v) + else: + local_tz = dt.datetime.now().astimezone().tzinfo + localized_dt = v.replace(tzinfo=local_tz) + return _serialize_zoned_datetime(localized_dt) diff --git a/src/roamhq/core/file.py b/src/roamhq/core/file.py new file mode 100644 index 0000000..f28f845 --- /dev/null +++ b/src/roamhq/core/file.py @@ -0,0 +1,69 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import IO, Dict, List, Mapping, Optional, Tuple, Union, cast + +# File typing inspired by the flexibility of types within the httpx library +# https://github.com/encode/httpx/blob/master/httpx/_types.py +FileContent = Union[IO[bytes], bytes, str] +File = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[ + Optional[str], + FileContent, + Optional[str], + Mapping[str, str], + ], +] + + +def convert_file_dict_to_httpx_tuples( + d: Dict[str, Union[File, List[File]]], +) -> List[Tuple[str, File]]: + """ + The format we use is a list of tuples, where the first element is the + name of the file and the second is the file object. Typically HTTPX wants + a dict, but to be able to send lists of files, you have to use the list + approach (which also works for non-lists) + https://github.com/encode/httpx/pull/1032 + """ + + httpx_tuples = [] + for key, file_like in d.items(): + if isinstance(file_like, list): + for file_like_item in file_like: + httpx_tuples.append((key, file_like_item)) + else: + httpx_tuples.append((key, file_like)) + return httpx_tuples + + +def with_content_type(*, file: File, default_content_type: str) -> File: + """ + This function resolves to the file's content type, if provided, and defaults + to the default_content_type value if not. + """ + if isinstance(file, tuple): + if len(file) == 2: + filename, content = cast(Tuple[Optional[str], FileContent], file) # type: ignore + return (filename, content, default_content_type) + elif len(file) == 3: + filename, content, file_content_type = cast(Tuple[Optional[str], FileContent, Optional[str]], file) # type: ignore + out_content_type = file_content_type or default_content_type + return (filename, content, out_content_type) + elif len(file) == 4: + filename, content, file_content_type, headers = cast( # type: ignore + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], file + ) + out_content_type = file_content_type or default_content_type + return (filename, content, out_content_type, headers) + else: + raise ValueError(f"Unexpected tuple length: {len(file)}") + return (None, file, default_content_type) diff --git a/src/roamhq/core/force_multipart.py b/src/roamhq/core/force_multipart.py new file mode 100644 index 0000000..d1bf973 --- /dev/null +++ b/src/roamhq/core/force_multipart.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import Any, Dict + + +class ForceMultipartDict(Dict[str, Any]): + """ + A dictionary subclass that always evaluates to True in boolean contexts. + + This is used to force multipart/form-data encoding in HTTP requests even when + the dictionary is empty, which would normally evaluate to False. + """ + + def __bool__(self) -> bool: + return True + + +FORCE_MULTIPART = ForceMultipartDict() diff --git a/src/roamhq/core/http_client.py b/src/roamhq/core/http_client.py new file mode 100644 index 0000000..4bf606c --- /dev/null +++ b/src/roamhq/core/http_client.py @@ -0,0 +1,942 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import asyncio +import email.utils +import re +import socket +import time +import typing +from contextlib import asynccontextmanager, contextmanager +from random import random + +import httpx +from .file import File, convert_file_dict_to_httpx_tuples +from .force_multipart import FORCE_MULTIPART +from .jsonable_encoder import jsonable_encoder +from .logging import LogConfig, Logger, create_logger +from .query_encoder import encode_query +from .remove_none_from_dict import remove_none_from_dict as remove_none_from_dict +from .request_options import RequestOptions +from httpx._types import RequestFiles + +INITIAL_RETRY_DELAY_SECONDS = 1.0 +MAX_RETRY_DELAY_SECONDS = 60.0 +JITTER_FACTOR = 0.2 # 20% random jitter + + +def get_keepalive_socket_options( + idle: int = 60, + intvl: int = 30, + cnt: int = 5, +) -> typing.List[typing.Tuple[int, int, int]]: + """ + Build TCP keepalive socket options for the current platform. + + Keepalive probes keep otherwise-idle connections alive so that long, + non-streaming requests survive idle-connection reaping by a firewall, + load balancer, or NAT. The available socket constants are OS-dependent, + so each option is guarded and only emitted when the platform defines it: + + - ``SO_KEEPALIVE`` is portable (Linux/macOS/Windows). + - The idle-before-first-probe knob is ``TCP_KEEPIDLE`` on Linux and modern + Windows, but ``TCP_KEEPALIVE`` on macOS. + - ``TCP_KEEPINTVL`` / ``TCP_KEEPCNT`` exist on Linux/macOS/modern Windows. + + Passing these tuples to ``httpx.HTTPTransport(socket_options=...)`` / + ``httpx.AsyncHTTPTransport(socket_options=...)`` applies them to every + connection the transport opens. + """ + opts: typing.List[typing.Tuple[int, int, int]] = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] + idle_const = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None) + if idle_const: + opts.append((socket.IPPROTO_TCP, idle_const, idle)) + if hasattr(socket, "TCP_KEEPINTVL"): + opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, intvl)) + if hasattr(socket, "TCP_KEEPCNT"): + opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, cnt)) + return opts + + +def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: + """ + This function parses the `Retry-After` header in a HTTP response and returns the number of seconds to wait. + + Inspired by the urllib3 retry implementation. + """ + retry_after_ms = response_headers.get("retry-after-ms") + if retry_after_ms is not None: + try: + return int(retry_after_ms) / 1000 if retry_after_ms > 0 else 0 + except Exception: + pass + + retry_after = response_headers.get("retry-after") + if retry_after is None: + return None + + # Attempt to parse the header as an int. + if re.match(r"^\s*[0-9]+\s*$", retry_after): + seconds = float(retry_after) + # Fallback to parsing it as a date. + else: + retry_date_tuple = email.utils.parsedate_tz(retry_after) + if retry_date_tuple is None: + return None + if retry_date_tuple[9] is None: # Python 2 + # Assume UTC if no timezone was specified + # On Python2.7, parsedate_tz returns None for a timezone offset + # instead of 0 if no timezone is given, where mktime_tz treats + # a None timezone offset as local time. + retry_date_tuple = retry_date_tuple[:9] + (0,) + retry_date_tuple[10:] + + retry_date = email.utils.mktime_tz(retry_date_tuple) + seconds = retry_date - time.time() + + if seconds < 0: + seconds = 0 + + return seconds + + +def _add_positive_jitter(delay: float) -> float: + """Add positive jitter (0-20%) to prevent thundering herd.""" + jitter_multiplier = 1 + random() * JITTER_FACTOR + return delay * jitter_multiplier + + +def _add_symmetric_jitter(delay: float) -> float: + """Add symmetric jitter (±10%) for exponential backoff.""" + jitter_multiplier = 1 + (random() - 0.5) * JITTER_FACTOR + return delay * jitter_multiplier + + +def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional[float]: + """ + Parse the X-RateLimit-Reset header (Unix timestamp in seconds). + Returns seconds to wait, or None if header is missing/invalid. + """ + reset_time_str = response_headers.get("x-ratelimit-reset") + if reset_time_str is None: + return None + + try: + reset_time = int(reset_time_str) + delay = reset_time - time.time() + if delay > 0: + return delay + except (ValueError, TypeError): + pass + + return None + + +def _retry_timeout(response: httpx.Response, retries: int) -> float: + """ + Determine the amount of time to wait before retrying a request. + This function begins by trying to parse a retry-after header from the response, and then proceeds to use exponential backoff + with a jitter to determine the number of seconds to wait. + """ + + # 1. Check Retry-After header first + retry_after = _parse_retry_after(response.headers) + if retry_after is not None and retry_after > 0: + return min(retry_after, MAX_RETRY_DELAY_SECONDS) + + # 2. Check X-RateLimit-Reset header (with positive jitter) + ratelimit_reset = _parse_x_ratelimit_reset(response.headers) + if ratelimit_reset is not None: + return _add_positive_jitter(min(ratelimit_reset, MAX_RETRY_DELAY_SECONDS)) + + # 3. Fall back to exponential backoff (with symmetric jitter) + backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + return _add_symmetric_jitter(backoff) + + +def _retry_timeout_from_retries(retries: int) -> float: + """Determine retry timeout using exponential backoff when no response is available.""" + backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + return _add_symmetric_jitter(backoff) + + +def _should_retry(response: httpx.Response) -> bool: + return response.status_code >= 500 or response.status_code in [429, 408, 409] + + +_SENSITIVE_HEADERS = frozenset( + { + "authorization", + "www-authenticate", + "x-api-key", + "api-key", + "apikey", + "x-api-token", + "x-auth-token", + "auth-token", + "cookie", + "set-cookie", + "proxy-authorization", + "proxy-authenticate", + "x-csrf-token", + "x-xsrf-token", + "x-session-token", + "x-access-token", + } +) + + +def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]: + return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()} + + +def _build_url(base_url: str, path: typing.Optional[str]) -> str: + """ + Build a full URL by joining a base URL with a path. + + This function correctly handles base URLs that contain path prefixes (e.g., tenant-based URLs) + by using string concatenation instead of urllib.parse.urljoin(), which would incorrectly + strip path components when the path starts with '/'. + + Example: + >>> _build_url("https://cloud.example.com/org/tenant/api", "/users") + 'https://cloud.example.com/org/tenant/api/users' + + Args: + base_url: The base URL, which may contain path prefixes. + path: The path to append. Can be None or empty string. + + Returns: + The full URL with base_url and path properly joined. + """ + if not path: + return base_url + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" + + +def _maybe_filter_none_from_multipart_data( + data: typing.Optional[typing.Any], + request_files: typing.Optional[RequestFiles], + force_multipart: typing.Optional[bool], +) -> typing.Optional[typing.Any]: + """ + Filter None values from data body for multipart/form requests. + This prevents httpx from converting None to empty strings in multipart encoding. + Only applies when files are present or force_multipart is True. + """ + if data is not None and isinstance(data, typing.Mapping) and (request_files or force_multipart): + return remove_none_from_dict(data) + return data + + +def remove_omit_from_dict( + original: typing.Dict[str, typing.Optional[typing.Any]], + omit: typing.Optional[typing.Any], +) -> typing.Dict[str, typing.Any]: + if omit is None: + return original + new: typing.Dict[str, typing.Any] = {} + for key, value in original.items(): + if value is not omit: + new[key] = value + return new + + +def maybe_filter_request_body( + data: typing.Optional[typing.Any], + request_options: typing.Optional[RequestOptions], + omit: typing.Optional[typing.Any], +) -> typing.Optional[typing.Any]: + if data is None: + return ( + jsonable_encoder(request_options.get("additional_body_parameters", {})) or {} + if request_options is not None + else None + ) + elif not isinstance(data, typing.Mapping): + data_content = jsonable_encoder(data) + else: + data_content = { + **(jsonable_encoder(remove_omit_from_dict(data, omit))), # type: ignore + **( + jsonable_encoder(request_options.get("additional_body_parameters", {})) or {} + if request_options is not None + else {} + ), + } + return data_content + + +# Abstracted out for testing purposes +def get_request_body( + *, + json: typing.Optional[typing.Any], + data: typing.Optional[typing.Any], + request_options: typing.Optional[RequestOptions], + omit: typing.Optional[typing.Any], + optional_body: bool = False, +) -> typing.Tuple[typing.Optional[typing.Any], typing.Optional[typing.Any]]: + # A whole body left at the sentinel was never passed by the caller, so it is absent + # rather than empty: the request carries no content and no `Content-Type`. + if omit is not None: + if json is omit: + json = None + if data is omit: + data = None + + json_body = None + data_body = None + if data is not None: + data_body = maybe_filter_request_body(data, request_options, omit) + else: + # If both data and json are None, we send json data in the event extra properties are specified + json_body = maybe_filter_request_body(json, request_options, omit) + + has_additional_body_parameters = bool( + request_options is not None and request_options.get("additional_body_parameters") + ) + + # Only collapse empty dict to None when the body was not explicitly provided + # and there are no additional body parameters. This preserves explicit empty + # bodies (e.g., when an endpoint has a request body type but all fields are optional). + # `optional_body` marks an endpoint whose body the API does not require, where a body + # that ends up empty means the caller passed none of its properties, so the request is + # sent with no content and no `Content-Type`. + if json_body == {} and (json is None or optional_body) and not has_additional_body_parameters: + json_body = None + if data_body == {} and (data is None or optional_body) and not has_additional_body_parameters: + data_body = None + + return json_body, data_body + + +def drop_content_type_without_body( + headers: typing.Dict[str, typing.Any], + *, + json_body: typing.Optional[typing.Any], + data_body: typing.Optional[typing.Any], + optional_body: bool, +) -> typing.Dict[str, typing.Any]: + """Strip ``Content-Type`` from a request that carries no body. + + ``get_request_body`` drops the body of an ``optional_body`` endpoint when the caller + supplied none of it, but the endpoint still passes the content type it would have used. + A request that sends nothing must not advertise a media type, so a server that branches + on the header sees a bodyless call for what it is. + """ + if not optional_body or json_body is not None or data_body is not None: + return headers + return {key: value for key, value in headers.items() if key.lower() != "content-type"} + + +class HttpClient: + def __init__( + self, + *, + httpx_client: httpx.Client, + base_timeout: typing.Callable[[], typing.Optional[float]], + base_headers: typing.Callable[[], typing.Dict[str, str]], + base_url: typing.Optional[typing.Callable[[], str]] = None, + base_max_retries: int = 2, + logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, + ): + self.base_url = base_url + self.base_timeout = base_timeout + self.base_headers = base_headers + self.base_max_retries = base_max_retries + self.httpx_client = httpx_client + self.logger = create_logger(logging_config) + + def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: + base_url = maybe_base_url + if self.base_url is not None and base_url is None: + base_url = self.base_url() + + if base_url is None: + raise ValueError("A base_url is required to make this request, please provide one and try again.") + return base_url + + def request( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[ + typing.Union[ + typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]], + typing.List[typing.Tuple[str, File]], + ] + ] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, + force_multipart: typing.Optional[bool] = None, + ) -> httpx.Response: + base_url = self.get_base_url(base_url) + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout() + ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT + + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) + + request_files: typing.Optional[RequestFiles] = ( + convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) + if (files is not None and files is not omit and isinstance(files, dict)) + else None + ) + + if (request_files is None or len(request_files) == 0) and force_multipart: + request_files = FORCE_MULTIPART + + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers(), + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + has_body=json_body is not None or data_body is not None, + ) + + max_retries: int = ( + request_options.get("max_retries", self.base_max_retries) + if request_options is not None + else self.base_max_retries + ) + + try: + response = self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, + json=json_body, + data=data_body, + content=content, + files=request_files, + timeout=timeout, + ) + except (httpx.ConnectError, httpx.RemoteProtocolError): + if retries < max_retries: + time.sleep(_retry_timeout_from_retries(retries=retries)) + return self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + data=data, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + force_multipart=force_multipart, + ) + raise + + if _should_retry(response=response): + if retries < max_retries: + time.sleep(_retry_timeout(response=response, retries=retries)) + return self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + data=data, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + force_multipart=force_multipart, + ) + + if self.logger.is_debug(): + if 200 <= response.status_code < 400: + self.logger.debug( + "HTTP request succeeded", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + if self.logger.is_error(): + if response.status_code >= 400: + self.logger.error( + "HTTP request failed with error status", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + return response + + @contextmanager + def stream( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[ + typing.Union[ + typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]], + typing.List[typing.Tuple[str, File]], + ] + ] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, + force_multipart: typing.Optional[bool] = None, + ) -> typing.Iterator[httpx.Response]: + base_url = self.get_base_url(base_url) + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout() + ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT + + request_files: typing.Optional[RequestFiles] = ( + convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) + if (files is not None and files is not omit and isinstance(files, dict)) + else None + ) + + if (request_files is None or len(request_files) == 0) and force_multipart: + request_files = FORCE_MULTIPART + + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) + + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers(), + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making streaming HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + ) + + with self.httpx_client.stream( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, + json=json_body, + data=data_body, + content=content, + files=request_files, + timeout=timeout, + ) as stream: + yield stream + + +class AsyncHttpClient: + def __init__( + self, + *, + httpx_client: httpx.AsyncClient, + base_timeout: typing.Callable[[], typing.Optional[float]], + base_headers: typing.Callable[[], typing.Dict[str, str]], + base_url: typing.Optional[typing.Callable[[], str]] = None, + base_max_retries: int = 2, + async_base_headers: typing.Optional[typing.Callable[[], typing.Awaitable[typing.Dict[str, str]]]] = None, + logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, + ): + self.base_url = base_url + self.base_timeout = base_timeout + self.base_headers = base_headers + self.base_max_retries = base_max_retries + self.async_base_headers = async_base_headers + self.httpx_client = httpx_client + self.logger = create_logger(logging_config) + + async def _get_headers(self) -> typing.Dict[str, str]: + if self.async_base_headers is not None: + return await self.async_base_headers() + return self.base_headers() + + def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: + base_url = maybe_base_url + if self.base_url is not None and base_url is None: + base_url = self.base_url() + + if base_url is None: + raise ValueError("A base_url is required to make this request, please provide one and try again.") + return base_url + + async def request( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[ + typing.Union[ + typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]], + typing.List[typing.Tuple[str, File]], + ] + ] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, + force_multipart: typing.Optional[bool] = None, + ) -> httpx.Response: + base_url = self.get_base_url(base_url) + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout() + ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT + + request_files: typing.Optional[RequestFiles] = ( + convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) + if (files is not None and files is not omit and isinstance(files, dict)) + else None + ) + + if (request_files is None or len(request_files) == 0) and force_multipart: + request_files = FORCE_MULTIPART + + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) + + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + has_body=json_body is not None or data_body is not None, + ) + + max_retries: int = ( + request_options.get("max_retries", self.base_max_retries) + if request_options is not None + else self.base_max_retries + ) + + try: + response = await self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, + json=json_body, + data=data_body, + content=content, + files=request_files, + timeout=timeout, + ) + except (httpx.ConnectError, httpx.RemoteProtocolError): + if retries < max_retries: + await asyncio.sleep(_retry_timeout_from_retries(retries=retries)) + return await self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + data=data, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + force_multipart=force_multipart, + ) + raise + + if _should_retry(response=response): + if retries < max_retries: + await asyncio.sleep(_retry_timeout(response=response, retries=retries)) + return await self.request( + path=path, + method=method, + base_url=base_url, + params=params, + json=json, + data=data, + content=content, + files=files, + headers=headers, + request_options=request_options, + retries=retries + 1, + omit=omit, + force_multipart=force_multipart, + ) + + if self.logger.is_debug(): + if 200 <= response.status_code < 400: + self.logger.debug( + "HTTP request succeeded", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + if self.logger.is_error(): + if response.status_code >= 400: + self.logger.error( + "HTTP request failed with error status", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + return response + + @asynccontextmanager + async def stream( + self, + path: typing.Optional[str] = None, + *, + method: str, + base_url: typing.Optional[str] = None, + params: typing.Optional[typing.Dict[str, typing.Any]] = None, + json: typing.Optional[typing.Any] = None, + data: typing.Optional[typing.Any] = None, + content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None, + files: typing.Optional[ + typing.Union[ + typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]], + typing.List[typing.Tuple[str, File]], + ] + ] = None, + headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_options: typing.Optional[RequestOptions] = None, + retries: int = 0, + omit: typing.Optional[typing.Any] = None, + optional_body: bool = False, + force_multipart: typing.Optional[bool] = None, + ) -> typing.AsyncIterator[httpx.Response]: + base_url = self.get_base_url(base_url) + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") + if request_options is not None and request_options.get("timeout_in_seconds") is not None + else self.base_timeout() + ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT + + request_files: typing.Optional[RequestFiles] = ( + convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) + if (files is not None and files is not omit and isinstance(files, dict)) + else None + ) + + if (request_files is None or len(request_files) == 0) and force_multipart: + request_files = FORCE_MULTIPART + + json_body, data_body = get_request_body( + json=json, data=data, request_options=request_options, omit=omit, optional_body=optional_body + ) + + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit=omit, + ) + ) + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ) + _request_headers = drop_content_type_without_body( + _request_headers, json_body=json_body, data_body=data_body, optional_body=optional_body + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making streaming HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + ) + + async with self.httpx_client.stream( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, + json=json_body, + data=data_body, + content=content, + files=request_files, + timeout=timeout, + ) as stream: + yield stream diff --git a/src/roamhq/core/http_response.py b/src/roamhq/core/http_response.py new file mode 100644 index 0000000..3952b91 --- /dev/null +++ b/src/roamhq/core/http_response.py @@ -0,0 +1,65 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import Dict, Generic, TypeVar + +import httpx + +# Generic to represent the underlying type of the data wrapped by the HTTP response. +T = TypeVar("T") + + +class BaseHttpResponse: + """Minimalist HTTP response wrapper that exposes response headers and status code.""" + + _response: httpx.Response + + def __init__(self, response: httpx.Response): + self._response = response + + @property + def headers(self) -> Dict[str, str]: + return dict(self._response.headers) + + @property + def status_code(self) -> int: + return self._response.status_code + + @property + def response(self) -> httpx.Response: + return self._response + + +class HttpResponse(Generic[T], BaseHttpResponse): + """HTTP response wrapper that exposes response headers and data.""" + + _data: T + + def __init__(self, response: httpx.Response, data: T): + super().__init__(response) + self._data = data + + @property + def data(self) -> T: + return self._data + + def close(self) -> None: + self._response.close() + + +class AsyncHttpResponse(Generic[T], BaseHttpResponse): + """HTTP response wrapper that exposes response headers and data.""" + + _data: T + + def __init__(self, response: httpx.Response, data: T): + super().__init__(response) + self._data = data + + @property + def data(self) -> T: + return self._data + + async def close(self) -> None: + await self._response.aclose() diff --git a/src/roamhq/core/http_sse/__init__.py b/src/roamhq/core/http_sse/__init__.py new file mode 100644 index 0000000..2662be8 --- /dev/null +++ b/src/roamhq/core/http_sse/__init__.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from ._api import EventSource, aconnect_sse, connect_sse + from ._exceptions import SSEError + from ._models import ServerSentEvent +_dynamic_imports: typing.Dict[str, str] = { + "EventSource": "._api", + "SSEError": "._exceptions", + "ServerSentEvent": "._models", + "aconnect_sse": "._api", + "connect_sse": "._api", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["EventSource", "SSEError", "ServerSentEvent", "aconnect_sse", "connect_sse"] diff --git a/src/roamhq/core/http_sse/_api.py b/src/roamhq/core/http_sse/_api.py new file mode 100644 index 0000000..c1cd652 --- /dev/null +++ b/src/roamhq/core/http_sse/_api.py @@ -0,0 +1,457 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import codecs +import re +import time +from contextlib import asynccontextmanager, contextmanager +from typing import ( + Any, + AsyncContextManager, + AsyncGenerator, + AsyncIterator, + Callable, + ContextManager, + Iterator, + Optional, +) + +import anyio +import httpx +from ._decoders import SSEDecoder +from ._exceptions import SSEError +from ._models import ServerSentEvent + +MAX_LINE_SIZE: int = 1_048_576 # 1 MiB + +# Reconnection defaults, mirroring the TypeScript SDK's Stream implementation. +DEFAULT_MAX_RECONNECTION_ATTEMPTS: int = 5 +DEFAULT_RECONNECT_DELAY_MS: int = 1_000 +MAX_RECONNECT_DELAY_MS: int = 30_000 + + +# A reconnect callback re-issues the original request (with a ``Last-Event-ID`` +# header set to the supplied event id) and returns a *context manager* yielding +# a fresh streaming ``httpx.Response``. Sync clients supply a sync context +# manager; async clients supply an async one. +class EventSource: + def __init__( + self, + response: httpx.Response, + *, + resumable: bool = False, + stream_reconnection_enabled: bool = True, + max_stream_reconnection_attempts: Optional[int] = None, + stream_terminator: Optional[str] = None, + reconnect: Optional[Callable[[str], Any]] = None, + ) -> None: + self._response = response + self._resumable = resumable + self._stream_reconnection_enabled = stream_reconnection_enabled + self._max_stream_reconnection_attempts = max_stream_reconnection_attempts + self._stream_terminator = stream_terminator + self._reconnect = reconnect + + @staticmethod + def _is_event_stream(response: httpx.Response) -> bool: + content_type = response.headers.get("content-type", "").partition(";")[0] + return "text/event-stream" in content_type + + def _check_content_type(self) -> None: + if not self._is_event_stream(self._response): + content_type = self._response.headers.get("content-type", "").partition(";")[0] + raise SSEError( + f"Expected response header Content-Type to contain 'text/event-stream', got {content_type!r}" + ) + + def _is_reconnect_response_usable(self, response: httpx.Response) -> bool: + """Whether a reconnected response can be resumed as an SSE stream. + + ``httpx.stream`` does not raise on non-success status, so a resume that + returns an error page (e.g. ``200 text/html`` or a ``500`` body) would + otherwise be parsed as SSE and yield garbage/zero events. Such a + response is treated as a failed attempt (back off and retry) instead. + """ + return response.status_code < 400 and self._is_event_stream(response) + + def _get_charset(self, response: Optional[httpx.Response] = None) -> str: + """Extract charset from Content-Type header, fallback to UTF-8.""" + resolved = response if response is not None else self._response + content_type = resolved.headers.get("content-type", "") + + # Parse charset parameter using regex + charset_match = re.search(r"charset=([^;\s]+)", content_type, re.IGNORECASE) + if charset_match: + charset = charset_match.group(1).strip("\"'") + # Validate that it's a known encoding + try: + # Test if the charset is valid by trying to encode/decode + "test".encode(charset).decode(charset) + return charset + except (LookupError, UnicodeError): + # If charset is invalid, fall back to UTF-8 + pass + + # Default to UTF-8 if no charset specified or invalid charset + return "utf-8" + + @property + def response(self) -> httpx.Response: + return self._response + + @staticmethod + def _normalize_sse_line_endings(buf: str) -> str: + """Normalize line endings per the SSE spec (\\r\\n → \\n, bare \\r → \\n). + + A trailing \\r is preserved because it may pair with a leading \\n in + the next chunk to form a single \\r\\n terminator. + """ + buf = buf.replace("\r\n", "\n") + if buf.endswith("\r"): + return buf[:-1].replace("\r", "\n") + "\r" + return buf.replace("\r", "\n") + + def _new_text_decoder(self, response: Optional[httpx.Response] = None) -> "codecs.IncrementalDecoder": + return codecs.getincrementaldecoder(self._get_charset(response))(errors="replace") + + def _reconnect_applicable(self) -> bool: + """Whether reconnection is configured for this stream at all. + + This is the terminator-gating half of the reconnect decision, kept + separate from :meth:`_should_reconnect` (which additionally requires a + last *dispatched* id and an unexhausted attempt budget). The split lets + a mid-stream transport error terminate consistently: + - a stream that can never reconnect (non-resumable, no terminator, + disabled, or no callback) must re-raise the error to the caller, so a + truncated stream is not mistaken for a clean completion; + - a resumable stream that has merely run out of attempts (or has no id + to resume from) ends cleanly — the same way an exhausted empty/error + -body resume already does, matching the TypeScript ``return``. + """ + return ( + self._resumable + and self._stream_terminator is not None + and self._stream_reconnection_enabled + and self._reconnect is not None + ) + + def _should_reconnect(self, last_dispatched_id: Optional[str], reconnect_attempts: int) -> bool: + """Decide whether a prematurely-ended stream should be reconnected. + + Mirrors the TypeScript ``shouldReconnect`` gating: + - only resumable SSE endpoints with a configured terminator, reconnect + enabled, and a reconnect callback are eligible (see + :meth:`_reconnect_applicable`); + - a last *dispatched* event id must exist to resume from; + - the consecutive-failed-attempt cap must not be exceeded. + """ + if not self._reconnect_applicable(): + return False + if not last_dispatched_id: + return False + max_attempts = ( + self._max_stream_reconnection_attempts + if self._max_stream_reconnection_attempts is not None + else DEFAULT_MAX_RECONNECTION_ATTEMPTS + ) + if reconnect_attempts >= max_attempts: + return False + return True + + def _reconnect_delay_seconds(self, last_retry: Optional[int]) -> float: + """Backoff before a reconnect. + + Uses the server's most recent ``retry:`` directive (milliseconds) when + present, otherwise a default of ``DEFAULT_RECONNECT_DELAY_MS``, clamped + to ``MAX_RECONNECT_DELAY_MS``. + """ + base_ms = last_retry if (last_retry is not None and last_retry > 0) else DEFAULT_RECONNECT_DELAY_MS + return min(base_ms, MAX_RECONNECT_DELAY_MS) / 1000.0 + + def _sleep_before_reconnect(self, last_retry: Optional[int]) -> None: + # ``time.sleep`` blocks the calling thread but remains interruptible by + # signals (e.g. ``KeyboardInterrupt``), which propagate out and abort + # the reconnect without issuing another request. + time.sleep(self._reconnect_delay_seconds(last_retry)) + + async def _asleep_before_reconnect(self, last_retry: Optional[int]) -> None: + # ``anyio.sleep`` is cancellation-aware: if the consumer cancels the task + # or closes the async generator mid-delay, this raises (and no further + # request is issued) instead of blocking for the whole interval. + await anyio.sleep(self._reconnect_delay_seconds(last_retry)) + + def _decode_response( + self, + response: httpx.Response, + decoder: SSEDecoder, + text_decoder: "codecs.IncrementalDecoder", + ) -> Iterator[ServerSentEvent]: + buf = "" + for chunk in response.iter_bytes(): + buf += text_decoder.decode(chunk) + buf = self._normalize_sse_line_endings(buf) + + while "\n" in buf: + line, buf = buf.split("\n", 1) + sse = decoder.decode(line) + if sse is not None: + yield sse + + if len(buf) > MAX_LINE_SIZE: + raise SSEError( + f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" + ) + + yield from self._flush_decoder(buf, decoder, text_decoder) + + async def _adecode_response( + self, + response: httpx.Response, + decoder: SSEDecoder, + text_decoder: "codecs.IncrementalDecoder", + ) -> AsyncGenerator[ServerSentEvent, None]: + buf = "" + async for chunk in response.aiter_bytes(): + buf += text_decoder.decode(chunk) + buf = self._normalize_sse_line_endings(buf) + + while "\n" in buf: + line, buf = buf.split("\n", 1) + sse = decoder.decode(line) + if sse is not None: + yield sse + + if len(buf) > MAX_LINE_SIZE: + raise SSEError( + f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" + ) + + for sse in self._flush_decoder(buf, decoder, text_decoder): + yield sse + + def _flush_decoder( + self, + buf: str, + decoder: SSEDecoder, + text_decoder: "codecs.IncrementalDecoder", + ) -> Iterator[ServerSentEvent]: + # Flush any remaining bytes from the incremental decoder + buf += text_decoder.decode(b"", final=True) + buf = buf.replace("\r\n", "\n").replace("\r", "\n") + + if len(buf) > MAX_LINE_SIZE: + raise SSEError( + f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" + ) + + while "\n" in buf: + line, buf = buf.split("\n", 1) + sse = decoder.decode(line) + if sse is not None: + yield sse + + if buf.strip(): + sse = decoder.decode(buf) + if sse is not None: + yield sse + + def iter_sse(self) -> Iterator[ServerSentEvent]: + self._check_content_type() + decoder = SSEDecoder() + text_decoder = self._new_text_decoder() + + last_dispatched_id: Optional[str] = None + last_retry: Optional[int] = None + # Consecutive failed reconnection attempts. Reset to 0 whenever an event + # is successfully dispatched (reset-on-progress) — matching browser + # `EventSource` semantics: a server that emits >=1 event then drops on + # every connection can reconnect indefinitely. + reconnect_attempts = 0 + + # ``None`` means there is no live stream to read this iteration (e.g. a + # failed reconnect); the loop then re-evaluates the reconnect decision + # without re-reading an exhausted response. + response: Optional[httpx.Response] = self._response + # Context manager for a response we opened ourselves and must close. + # The initial response is owned by the caller, so it starts as None. + owned_cm: Optional[ContextManager[httpx.Response]] = None + try: + while True: + if response is not None: + events = self._decode_response(response, decoder, text_decoder) + while True: + try: + sse = next(events) + except StopIteration: + break + except SSEError: + # A protocol violation (e.g. an oversized line) is a + # genuine error, not a dropped connection; propagate it. + # Listed first because ``SSEError`` subclasses + # ``httpx.TransportError``. + raise + except httpx.TransportError: + # A transport error mid-stream (e.g. the server dropped + # the connection: ``ReadError``/``RemoteProtocolError``) + # is a premature end. Only swallow it when reconnection + # is configured for this stream; otherwise re-raise so a + # non-resumable stream still surfaces the error to the + # caller instead of looking like a clean completion. + # When reconnection is applicable but the attempt budget + # is exhausted, we ``break`` and end cleanly below — the + # same way an exhausted empty/error-body resume does, so + # give-up is consistent regardless of failure shape. + # ``next`` is used rather than ``for`` so this cannot + # swallow a ``GeneratorExit`` raised at a ``yield``. + if not self._reconnect_applicable(): + raise + break + yield sse + if sse.id: + last_dispatched_id = sse.id + if sse.retry is not None: + last_retry = sse.retry + reconnect_attempts = 0 + + if not self._should_reconnect(last_dispatched_id, reconnect_attempts): + return + reconnect_attempts += 1 + + self._sleep_before_reconnect(last_retry) + + # Close the previously-opened reconnect response before opening + # a new one so we never hold more than one extra connection. + if owned_cm is not None: + owned_cm.__exit__(None, None, None) + owned_cm = None + + assert self._reconnect is not None # guaranteed by _should_reconnect + try: + cm: ContextManager[httpx.Response] = self._reconnect(last_dispatched_id or "") + new_response = cm.__enter__() + except Exception: + # A failed reconnect consumes an attempt; back off and retry. + response = None + continue + owned_cm = cm + if new_response is None or not self._is_reconnect_response_usable(new_response): + # Null/empty body or a non-SSE/error response (e.g. 204/304, + # a 500, or an HTML error page): treat as a failed attempt. + response = None + continue + + response = new_response + # Drop any partial event left over from the dropped stream, but + # keep the last event id (per the SSE spec) and start a fresh + # incremental text decoder for the new connection. + decoder.reset_in_progress_event() + text_decoder = self._new_text_decoder(new_response) + finally: + if owned_cm is not None: + owned_cm.__exit__(None, None, None) + + async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: + self._check_content_type() + decoder = SSEDecoder() + text_decoder = self._new_text_decoder() + + last_dispatched_id: Optional[str] = None + last_retry: Optional[int] = None + reconnect_attempts = 0 + + response: Optional[httpx.Response] = self._response + owned_cm: Optional[AsyncContextManager[httpx.Response]] = None + try: + while True: + if response is not None: + events = self._adecode_response(response, decoder, text_decoder) + while True: + try: + sse = await events.__anext__() + except StopAsyncIteration: + break + except SSEError: + # A protocol violation (e.g. an oversized line) is a + # genuine error, not a dropped connection; propagate it. + # Listed first because ``SSEError`` subclasses + # ``httpx.TransportError``. + raise + except httpx.TransportError: + # A transport error mid-stream (e.g. the server dropped + # the connection: ``ReadError``/``RemoteProtocolError``) + # is a premature end. Only swallow it when reconnection + # is configured for this stream; otherwise re-raise so a + # non-resumable stream still surfaces the error to the + # caller instead of looking like a clean completion. + # When reconnection is applicable but the attempt budget + # is exhausted, we ``break`` and end cleanly below — the + # same way an exhausted empty/error-body resume does, so + # give-up is consistent regardless of failure shape. + if not self._reconnect_applicable(): + raise + break + yield sse + if sse.id: + last_dispatched_id = sse.id + if sse.retry is not None: + last_retry = sse.retry + reconnect_attempts = 0 + + if not self._should_reconnect(last_dispatched_id, reconnect_attempts): + return + reconnect_attempts += 1 + + await self._asleep_before_reconnect(last_retry) + + if owned_cm is not None: + await owned_cm.__aexit__(None, None, None) + owned_cm = None + + assert self._reconnect is not None # guaranteed by _should_reconnect + try: + cm: AsyncContextManager[httpx.Response] = self._reconnect(last_dispatched_id or "") + new_response = await cm.__aenter__() + except Exception: + response = None + continue + owned_cm = cm + if new_response is None or not self._is_reconnect_response_usable(new_response): + response = None + continue + + response = new_response + decoder.reset_in_progress_event() + text_decoder = self._new_text_decoder(new_response) + finally: + if owned_cm is not None: + # Shield the close so a cancellation delivered while reading a + # reconnected response still fully tears the connection down + # instead of leaking it until the client is closed. + with anyio.CancelScope(shield=True): + await owned_cm.__aexit__(None, None, None) + + +@contextmanager +def connect_sse(client: httpx.Client, method: str, url: str, **kwargs: Any) -> Iterator[EventSource]: + headers = kwargs.pop("headers", {}) + headers["Accept"] = "text/event-stream" + headers["Cache-Control"] = "no-store" + + with client.stream(method, url, headers=headers, **kwargs) as response: + yield EventSource(response) + + +@asynccontextmanager +async def aconnect_sse( + client: httpx.AsyncClient, + method: str, + url: str, + **kwargs: Any, +) -> AsyncIterator[EventSource]: + headers = kwargs.pop("headers", {}) + headers["Accept"] = "text/event-stream" + headers["Cache-Control"] = "no-store" + + async with client.stream(method, url, headers=headers, **kwargs) as response: + yield EventSource(response) diff --git a/src/roamhq/core/http_sse/_decoders.py b/src/roamhq/core/http_sse/_decoders.py new file mode 100644 index 0000000..52ea7d0 --- /dev/null +++ b/src/roamhq/core/http_sse/_decoders.py @@ -0,0 +1,76 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import List, Optional + +from ._models import ServerSentEvent + + +class SSEDecoder: + def __init__(self) -> None: + self._event = "" + self._data: List[str] = [] + self._last_event_id = "" + self._retry: Optional[int] = None + + def reset_in_progress_event(self) -> None: + """Discard any partially-parsed (undispatched) event. + + Used when a stream ends mid-event before reconnecting: the buffered + ``event``/``data``/``retry`` fields of the never-dispatched event must + be dropped so they do not corrupt the first event of the reconnected + stream. Per the SSE spec the last event id is *not* reset here — it + persists across connections. + """ + self._event = "" + self._data = [] + self._retry = None + + def decode(self, line: str) -> Optional[ServerSentEvent]: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = "" + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None diff --git a/src/roamhq/core/http_sse/_exceptions.py b/src/roamhq/core/http_sse/_exceptions.py new file mode 100644 index 0000000..0f40628 --- /dev/null +++ b/src/roamhq/core/http_sse/_exceptions.py @@ -0,0 +1,9 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import httpx + + +class SSEError(httpx.TransportError): + pass diff --git a/src/roamhq/core/http_sse/_models.py b/src/roamhq/core/http_sse/_models.py new file mode 100644 index 0000000..78b715b --- /dev/null +++ b/src/roamhq/core/http_sse/_models.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass(frozen=True) +class ServerSentEvent: + event: str = "message" + data: str = "" + id: str = "" + retry: Optional[int] = None + + def json(self) -> Any: + """Parse the data field as JSON.""" + return json.loads(self.data) diff --git a/src/roamhq/core/jsonable_encoder.py b/src/roamhq/core/jsonable_encoder.py new file mode 100644 index 0000000..7e02431 --- /dev/null +++ b/src/roamhq/core/jsonable_encoder.py @@ -0,0 +1,135 @@ +# This file was auto-generated by Fern from our API Definition. + +""" +jsonable_encoder converts a Python object to a JSON-friendly dict +(e.g. datetimes to strings, Pydantic models to dicts). + +Taken from FastAPI, and made a bit simpler +https://github.com/tiangolo/fastapi/blob/master/fastapi/encoders.py +""" + +from __future__ import annotations + +import base64 +import dataclasses +import datetime as dt +from enum import Enum +from pathlib import PurePath +from types import GeneratorType +from typing import Any, Callable, Dict, List, Optional, Set, Union +from urllib.parse import quote + +import pydantic +from .datetime_utils import serialize_datetime +from .pydantic_utilities import ( + IS_PYDANTIC_V2, + encode_by_type, + to_jsonable_with_fallback, +) + +SetIntStr = Set[Union[int, str]] +DictIntStrAny = Dict[Union[int, str], Any] + + +def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None) -> Any: + custom_encoder = custom_encoder or {} + # Generated SDKs use Ellipsis (`...`) as the sentinel value for "OMIT". + # OMIT values should be excluded from serialized payloads. + if obj is Ellipsis: + return None + if custom_encoder: + if type(obj) in custom_encoder: + return custom_encoder[type(obj)](obj) + else: + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder_instance(obj) + if isinstance(obj, pydantic.BaseModel): + if IS_PYDANTIC_V2: + encoder = getattr(obj.model_config, "json_encoders", {}) # type: ignore # Pydantic v2 + else: + encoder = getattr(obj.__config__, "json_encoders", {}) # type: ignore # Pydantic v1 + if custom_encoder: + encoder.update(custom_encoder) + obj_dict = obj.dict(by_alias=True) + if "__root__" in obj_dict: + obj_dict = obj_dict["__root__"] + if "root" in obj_dict: + obj_dict = obj_dict["root"] + return jsonable_encoder(obj_dict, custom_encoder=encoder) + if dataclasses.is_dataclass(obj): + obj_dict = dataclasses.asdict(obj) # type: ignore + return jsonable_encoder(obj_dict, custom_encoder=custom_encoder) + if isinstance(obj, bytes): + return base64.b64encode(obj).decode("utf-8") + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, PurePath): + return str(obj) + if isinstance(obj, (str, int, float, type(None))): + return obj + if isinstance(obj, dt.datetime): + return serialize_datetime(obj) + if isinstance(obj, dt.date): + return str(obj) + if isinstance(obj, dict): + encoded_dict = {} + allowed_keys = set(obj.keys()) + for key, value in obj.items(): + if key in allowed_keys: + if value is Ellipsis: + continue + encoded_key = jsonable_encoder(key, custom_encoder=custom_encoder) + encoded_value = jsonable_encoder(value, custom_encoder=custom_encoder) + encoded_dict[encoded_key] = encoded_value + return encoded_dict + if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)): + encoded_list = [] + for item in obj: + if item is Ellipsis: + continue + encoded_list.append(jsonable_encoder(item, custom_encoder=custom_encoder)) + return encoded_list + + def fallback_serializer(o: Any) -> Any: + attempt_encode = encode_by_type(o) + if attempt_encode is not None: + return attempt_encode + + try: + data = dict(o) + except Exception as e: + errors: List[Exception] = [] + errors.append(e) + try: + data = vars(o) + except Exception as e: + errors.append(e) + raise ValueError(errors) from e + return jsonable_encoder(data, custom_encoder=custom_encoder) + + return to_jsonable_with_fallback(obj, fallback_serializer) + + +def encode_path_param(obj: Any) -> str: + """Encode a value for use in a URL path segment. + + Ensures proper string conversion for all types, including + booleans which need lowercase 'true'/'false' rather than + Python's 'True'/'False'. + """ + if isinstance(obj, bool): + return "true" if obj else "false" + return str(jsonable_encoder(obj)) + + +def quote_path_param(obj: Any) -> str: + """Encode a value for use in a URL path segment, percent-encoding it. + + Same as encode_path_param, except the result is percent-encoded so + that a value containing "/" or ".." cannot change which endpoint + the request resolves to. + """ + if isinstance(obj, bool): + return "true" if obj else "false" + return quote(str(jsonable_encoder(obj)), safe="") diff --git a/src/roamhq/core/logging.py b/src/roamhq/core/logging.py new file mode 100644 index 0000000..eea4f69 --- /dev/null +++ b/src/roamhq/core/logging.py @@ -0,0 +1,109 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import logging +import typing + +LogLevel = typing.Literal["debug", "info", "warn", "error"] + +_LOG_LEVEL_MAP: typing.Dict[LogLevel, int] = { + "debug": 1, + "info": 2, + "warn": 3, + "error": 4, +} + + +class ILogger(typing.Protocol): + def debug(self, message: str, **kwargs: typing.Any) -> None: ... + def info(self, message: str, **kwargs: typing.Any) -> None: ... + def warn(self, message: str, **kwargs: typing.Any) -> None: ... + def error(self, message: str, **kwargs: typing.Any) -> None: ... + + +class ConsoleLogger: + _logger: logging.Logger + + def __init__(self) -> None: + self._logger = logging.getLogger("fern") + if not self._logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s")) + self._logger.addHandler(handler) + self._logger.setLevel(logging.DEBUG) + + def debug(self, message: str, **kwargs: typing.Any) -> None: + self._logger.debug(message, extra=kwargs) + + def info(self, message: str, **kwargs: typing.Any) -> None: + self._logger.info(message, extra=kwargs) + + def warn(self, message: str, **kwargs: typing.Any) -> None: + self._logger.warning(message, extra=kwargs) + + def error(self, message: str, **kwargs: typing.Any) -> None: + self._logger.error(message, extra=kwargs) + + +class LogConfig(typing.TypedDict, total=False): + level: LogLevel + logger: ILogger + silent: bool + + +class Logger: + _level: int + _logger: ILogger + _silent: bool + + def __init__(self, *, level: LogLevel, logger: ILogger, silent: bool) -> None: + self._level = _LOG_LEVEL_MAP[level] + self._logger = logger + self._silent = silent + + def _should_log(self, level: LogLevel) -> bool: + return not self._silent and self._level <= _LOG_LEVEL_MAP[level] + + def is_debug(self) -> bool: + return self._should_log("debug") + + def is_info(self) -> bool: + return self._should_log("info") + + def is_warn(self) -> bool: + return self._should_log("warn") + + def is_error(self) -> bool: + return self._should_log("error") + + def debug(self, message: str, **kwargs: typing.Any) -> None: + if self.is_debug(): + self._logger.debug(message, **kwargs) + + def info(self, message: str, **kwargs: typing.Any) -> None: + if self.is_info(): + self._logger.info(message, **kwargs) + + def warn(self, message: str, **kwargs: typing.Any) -> None: + if self.is_warn(): + self._logger.warn(message, **kwargs) + + def error(self, message: str, **kwargs: typing.Any) -> None: + if self.is_error(): + self._logger.error(message, **kwargs) + + +_default_logger: Logger = Logger(level="info", logger=ConsoleLogger(), silent=True) + + +def create_logger(config: typing.Optional[typing.Union[LogConfig, Logger]] = None) -> Logger: + if config is None: + return _default_logger + if isinstance(config, Logger): + return config + return Logger( + level=config.get("level", "info"), + logger=config.get("logger", ConsoleLogger()), + silent=config.get("silent", True), + ) diff --git a/src/roamhq/core/pagination.py b/src/roamhq/core/pagination.py new file mode 100644 index 0000000..760b089 --- /dev/null +++ b/src/roamhq/core/pagination.py @@ -0,0 +1,82 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import AsyncIterator, Awaitable, Callable, Generic, Iterator, List, Optional, TypeVar + +# Generic to represent the underlying type of the results within a page +T = TypeVar("T") +# Generic to represent the type of the API response +R = TypeVar("R") + + +# SDKs implement a Page ABC per-pagination request, the endpoint then returns a pager that wraps this type +# for example, an endpoint will return SyncPager[UserPage] where UserPage implements the Page ABC. ex: +# +# SyncPager( +# has_next=response.list_metadata.after is not None, +# items=response.data, +# # This should be the outer function that returns the SyncPager again +# get_next=lambda: list(..., cursor: response.cursor) (or list(..., offset: offset + 1)) +# ) + + +@dataclass(frozen=True) +class SyncPager(Generic[T, R]): + get_next: Optional[Callable[[], Optional[SyncPager[T, R]]]] + has_next: bool + items: Optional[List[T]] + response: R + + # Here we type ignore the iterator to avoid a mypy error + # caused by the type conflict with Pydanitc's __iter__ method + # brought in by extending the base model + def __iter__(self) -> Iterator[T]: # type: ignore[override] + for page in self.iter_pages(): + if page.items is not None: + yield from page.items + + def iter_pages(self) -> Iterator[SyncPager[T, R]]: + page: Optional[SyncPager[T, R]] = self + while page is not None: + yield page + + if not page.has_next or page.get_next is None: + return + + page = page.get_next() + if page is None or page.items is None or len(page.items) == 0: + return + + def next_page(self) -> Optional[SyncPager[T, R]]: + return self.get_next() if self.get_next is not None else None + + +@dataclass(frozen=True) +class AsyncPager(Generic[T, R]): + get_next: Optional[Callable[[], Awaitable[Optional[AsyncPager[T, R]]]]] + has_next: bool + items: Optional[List[T]] + response: R + + async def __aiter__(self) -> AsyncIterator[T]: + async for page in self.iter_pages(): + if page.items is not None: + for item in page.items: + yield item + + async def iter_pages(self) -> AsyncIterator[AsyncPager[T, R]]: + page: Optional[AsyncPager[T, R]] = self + while page is not None: + yield page + + if not page.has_next or page.get_next is None: + return + + page = await page.get_next() + if page is None or page.items is None or len(page.items) == 0: + return + + async def next_page(self) -> Optional[AsyncPager[T, R]]: + return await self.get_next() if self.get_next is not None else None diff --git a/src/roamhq/core/parse_error.py b/src/roamhq/core/parse_error.py new file mode 100644 index 0000000..af93e16 --- /dev/null +++ b/src/roamhq/core/parse_error.py @@ -0,0 +1,38 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +class ParsingError(Exception): + """ + Raised when the SDK fails to parse/validate a response from the server. + This typically indicates that the server returned a response whose shape + does not match the expected schema. + """ + + headers: Optional[Dict[str, str]] + status_code: Optional[int] + body: Any + cause: Optional[Exception] + + def __init__( + self, + *, + headers: Optional[Dict[str, str]] = None, + status_code: Optional[int] = None, + body: Any = None, + cause: Optional[Exception] = None, + ) -> None: + self.headers = headers + self.status_code = status_code + self.body = body + self.cause = cause + super().__init__() + if cause is not None: + self.__cause__ = cause + + def __str__(self) -> str: + cause_str = f", cause: {self.cause}" if self.cause is not None else "" + return f"headers: {self.headers}, status_code: {self.status_code}, body: {self.body}{cause_str}" diff --git a/src/roamhq/core/pydantic_utilities.py b/src/roamhq/core/pydantic_utilities.py new file mode 100644 index 0000000..291f5fb --- /dev/null +++ b/src/roamhq/core/pydantic_utilities.py @@ -0,0 +1,488 @@ +# This file was auto-generated by Fern from our API Definition. + +# nopycln: file +from __future__ import annotations + +import datetime as dt +import inspect +import json +import logging +import weakref +from collections import defaultdict +from dataclasses import asdict +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Dict, + List, + Mapping, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, +) + +import pydantic +import typing_extensions +from pydantic.fields import FieldInfo as _FieldInfo + +_logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from .http_sse._models import ServerSentEvent + +IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + +if IS_PYDANTIC_V2: + _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined] + _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined] + + def parse_datetime(value: Any) -> dt.datetime: # type: ignore[misc] + if isinstance(value, dt.datetime): + return value + return _datetime_adapter.validate_python(value) + + def parse_date(value: Any) -> dt.date: # type: ignore[misc] + if isinstance(value, dt.datetime): + return value.date() + if isinstance(value, dt.date): + return value + return _date_adapter.validate_python(value) + + # Avoid importing from pydantic.v1 to maintain Python 3.14 compatibility. + from typing import get_args as get_args # type: ignore[assignment] + from typing import get_origin as get_origin # type: ignore[assignment] + + def is_literal_type(tp: Optional[Type[Any]]) -> bool: # type: ignore[misc] + return typing_extensions.get_origin(tp) is typing_extensions.Literal + + def is_union(tp: Optional[Type[Any]]) -> bool: # type: ignore[misc] + return tp is Union or typing_extensions.get_origin(tp) is Union # type: ignore[comparison-overlap] + + # Inline encoders_by_type to avoid importing from pydantic.v1.json + import re as _re + from collections import deque as _deque + from decimal import Decimal as _Decimal + from enum import Enum as _Enum + from ipaddress import ( + IPv4Address as _IPv4Address, + ) + from ipaddress import ( + IPv4Interface as _IPv4Interface, + ) + from ipaddress import ( + IPv4Network as _IPv4Network, + ) + from ipaddress import ( + IPv6Address as _IPv6Address, + ) + from ipaddress import ( + IPv6Interface as _IPv6Interface, + ) + from ipaddress import ( + IPv6Network as _IPv6Network, + ) + from pathlib import Path as _Path + from types import GeneratorType as _GeneratorType + from uuid import UUID as _UUID + + from pydantic.fields import FieldInfo as ModelField # type: ignore[no-redef, assignment] + + def _decimal_encoder(dec_value: Any) -> Any: + if dec_value.as_tuple().exponent >= 0: + return int(dec_value) + return float(dec_value) + + encoders_by_type: Dict[Type[Any], Callable[[Any], Any]] = { # type: ignore[no-redef] + bytes: lambda o: o.decode(), + dt.date: lambda o: o.isoformat(), + dt.datetime: lambda o: o.isoformat(), + dt.time: lambda o: o.isoformat(), + dt.timedelta: lambda td: td.total_seconds(), + _Decimal: _decimal_encoder, + _Enum: lambda o: o.value, + frozenset: list, + _deque: list, + _GeneratorType: list, + _IPv4Address: str, + _IPv4Interface: str, + _IPv4Network: str, + _IPv6Address: str, + _IPv6Interface: str, + _IPv6Network: str, + _Path: str, + _re.Pattern: lambda o: o.pattern, + set: list, + _UUID: str, + } +else: + from pydantic.datetime_parse import parse_date as parse_date # type: ignore[no-redef] + from pydantic.datetime_parse import parse_datetime as parse_datetime # type: ignore[no-redef] + from pydantic.fields import ModelField as ModelField # type: ignore[attr-defined, no-redef, assignment] + from pydantic.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore[no-redef] + from pydantic.typing import get_args as get_args # type: ignore[no-redef] + from pydantic.typing import get_origin as get_origin # type: ignore[no-redef] + from pydantic.typing import is_literal_type as is_literal_type # type: ignore[no-redef, assignment] + from pydantic.typing import is_union as is_union # type: ignore[no-redef] + +from .datetime_utils import serialize_datetime +from .serialization import convert_and_respect_annotation_metadata +from typing_extensions import TypeAlias + +T = TypeVar("T") +Model = TypeVar("Model", bound=pydantic.BaseModel) + + +def parse_sse_obj(sse: "ServerSentEvent", type_: Type[T]) -> T: + """ + Parse a ServerSentEvent into the appropriate type. + + This function handles data-level discrimination where the discriminator + (e.g., 'type') is inside the 'data' payload. It parses the SSE data field + as JSON and deserializes it into the target type. + + Note: Protocol-level discrimination (where the discriminator comes from + the SSE event: field) is handled at code-generation time and does not + use this function. + + Args: + sse: The ServerSentEvent object to parse + type_: The target type to deserialize into + + Returns: + The parsed object of type T + + Note: + This function is only available in SDK contexts where http_sse module exists. + """ + sse_event = asdict(sse) + data_value = sse_event.get("data") + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + return parse_obj_as(type_, parsed_data) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + return parse_obj_as(type_, sse_event) + + +_type_adapter_cache: Dict[int, Any] = {} + + +def _get_type_adapter(type_: Type[Any]) -> Any: + key = id(type_) + adapter = _type_adapter_cache.get(key) + if adapter is None: + adapter = pydantic.TypeAdapter(type_) # type: ignore[attr-defined] + _type_adapter_cache[key] = adapter + return adapter + + +_field_alias_cache: "weakref.WeakKeyDictionary[type, Tuple[Dict[str, str], Tuple[str, ...]]]" = ( + weakref.WeakKeyDictionary() +) + + +def _get_field_aliases(model: type) -> Tuple[Dict[str, str], Tuple[str, ...]]: + """ + Map of field name to Pydantic alias for the fields whose alias differs from their name, together with the + keys that are ambiguous (an alias of one field and the name of another). Computed once per model class. + """ + cached = _field_alias_cache.get(model) + if cached is None: + fields: Mapping[str, Any] = ( + getattr(model, "model_fields", {}) if IS_PYDANTIC_V2 else getattr(model, "__fields__", {}) + ) + name_to_alias: Dict[str, str] = {} + for name, field in fields.items(): + alias = getattr(field, "alias", None) + if alias is not None and alias != name: + name_to_alias[name] = alias + cached = (name_to_alias, tuple(alias for alias in name_to_alias.values() if alias in fields)) + _field_alias_cache[model] = cached + return cached + + +def _coerce_keys_to_aliases(model: type, data: Any) -> Any: + """ + Accept Python field names in input by rewriting them to their Pydantic aliases, + while avoiding silent collisions when a key could refer to multiple fields. + """ + if not isinstance(data, Mapping): + return data + + name_to_alias, ambiguous_keys = _get_field_aliases(model) + for key in ambiguous_keys: + if key in data and name_to_alias.get(key, key) not in data: + raise ValueError( + f"Ambiguous input key '{key}': it is both a field name and an alias. " + "Provide the explicit alias key to disambiguate." + ) + + if not name_to_alias or not any(name in data for name in name_to_alias): + return data if isinstance(data, dict) else dict(data) + + rewritten: Dict[str, Any] = dict(data) + for name, alias in name_to_alias.items(): + if name in data and alias not in rewritten: + rewritten[alias] = rewritten.pop(name) + + return rewritten + + +def parse_obj_as(type_: Type[T], object_: Any) -> T: + # convert_and_respect_annotation_metadata is required for TypedDict aliasing. + # + # For Pydantic models, whether we should pre-dealias depends on how the model encodes aliasing: + # - If the model uses real Pydantic aliases (pydantic.Field(alias=...)), then we must pass wire keys through + # unchanged so Pydantic can validate them. + # - If the model encodes aliasing only via FieldMetadata annotations, then we MUST pre-dealias because Pydantic + # will not recognize those aliases during validation. + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + has_pydantic_aliases = bool(_get_field_aliases(type_)[0]) + + dealiased_object = ( + object_ + if has_pydantic_aliases + else convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") + ) + else: + dealiased_object = convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") + if IS_PYDANTIC_V2: + adapter = _get_type_adapter(type_) + return adapter.validate_python(dealiased_object) # type: ignore[no-any-return] + return pydantic.parse_obj_as(type_, dealiased_object) + + +def to_jsonable_with_fallback(obj: Any, fallback_serializer: Callable[[Any], Any]) -> Any: + if IS_PYDANTIC_V2: + from pydantic_core import to_jsonable_python + + return to_jsonable_python(obj, fallback=fallback_serializer) + return fallback_serializer(obj) + + +class UniversalBaseModel(pydantic.BaseModel): + if IS_PYDANTIC_V2: + model_config: ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( # type: ignore[typeddict-unknown-key] + # Allow fields beginning with `model_` to be used in the model + protected_namespaces=(), + ) + + @pydantic.model_validator(mode="before") # type: ignore[attr-defined] + @classmethod + def _coerce_field_names_to_aliases(cls, data: Any) -> Any: + return _coerce_keys_to_aliases(cls, data) + + @pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined] + def serialize_model(self) -> Any: # type: ignore[name-defined] + serialized = self.dict() # type: ignore[attr-defined] + data = {k: serialize_datetime(v) if isinstance(v, dt.datetime) else v for k, v in serialized.items()} + return data + + else: + + class Config: + smart_union = True + json_encoders = {dt.datetime: serialize_datetime} + + @pydantic.root_validator(pre=True) + def _coerce_field_names_to_aliases(cls, values: Any) -> Any: + return _coerce_keys_to_aliases(cls, values) # type: ignore[arg-type] + + @classmethod + def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model": + dealiased_object = convert_and_respect_annotation_metadata(object_=values, annotation=cls, direction="read") + return cls.construct(_fields_set, **dealiased_object) + + @classmethod + def construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model": + dealiased_object = convert_and_respect_annotation_metadata(object_=values, annotation=cls, direction="read") + if IS_PYDANTIC_V2: + return super().model_construct(_fields_set, **dealiased_object) # type: ignore[misc] + return super().construct(_fields_set, **dealiased_object) + + def json(self, **kwargs: Any) -> str: + kwargs_with_defaults = { + "by_alias": True, + "exclude_unset": True, + **kwargs, + } + if IS_PYDANTIC_V2: + return super().model_dump_json(**kwargs_with_defaults) # type: ignore[misc] + return super().json(**kwargs_with_defaults) + + def dict(self, **kwargs: Any) -> Dict[str, Any]: + """ + Override the default dict method to `exclude_unset` by default. This function patches + `exclude_unset` to work include fields within non-None default values. + """ + # Note: the logic here is multiplexed given the levers exposed in Pydantic V1 vs V2 + # Pydantic V1's .dict can be extremely slow, so we do not want to call it twice. + # + # We'd ideally do the same for Pydantic V2, but it shells out to a library to serialize models + # that we have less control over, and this is less intrusive than custom serializers for now. + if IS_PYDANTIC_V2: + kwargs_with_defaults_exclude_unset = { + **kwargs, + "by_alias": True, + "exclude_unset": True, + "exclude_none": False, + } + kwargs_with_defaults_exclude_none = { + **kwargs, + "by_alias": True, + "exclude_none": True, + "exclude_unset": False, + } + dict_dump = deep_union_pydantic_dicts( + super().model_dump(**kwargs_with_defaults_exclude_unset), # type: ignore[misc] + super().model_dump(**kwargs_with_defaults_exclude_none), # type: ignore[misc] + ) + + else: + _fields_set = self.__fields_set__.copy() + + fields = _get_model_fields(self.__class__) + for name, field in fields.items(): + if name not in _fields_set: + default = _get_field_default(field) + + # If the default values are non-null act like they've been set + # This effectively allows exclude_unset to work like exclude_none where + # the latter passes through intentionally set none values. + if default is not None or ("exclude_unset" in kwargs and not kwargs["exclude_unset"]): + _fields_set.add(name) + + if default is not None: + self.__fields_set__.add(name) + + kwargs_with_defaults_exclude_unset_include_fields = { + "by_alias": True, + "exclude_unset": True, + "include": _fields_set, + **kwargs, + } + + dict_dump = super().dict(**kwargs_with_defaults_exclude_unset_include_fields) + + return cast( + Dict[str, Any], + convert_and_respect_annotation_metadata(object_=dict_dump, annotation=self.__class__, direction="write"), + ) + + +def _union_list_of_pydantic_dicts(source: List[Any], destination: List[Any]) -> List[Any]: + converted_list: List[Any] = [] + for i, item in enumerate(source): + destination_value = destination[i] + if isinstance(item, dict): + converted_list.append(deep_union_pydantic_dicts(item, destination_value)) + elif isinstance(item, list): + converted_list.append(_union_list_of_pydantic_dicts(item, destination_value)) + else: + converted_list.append(item) + return converted_list + + +def deep_union_pydantic_dicts(source: Dict[str, Any], destination: Dict[str, Any]) -> Dict[str, Any]: + for key, value in source.items(): + node = destination.setdefault(key, {}) + if isinstance(value, dict): + deep_union_pydantic_dicts(value, node) + # Note: we do not do this same processing for sets given we do not have sets of models + # and given the sets are unordered, the processing of the set and matching objects would + # be non-trivial. + elif isinstance(value, list): + destination[key] = _union_list_of_pydantic_dicts(value, node) + else: + destination[key] = value + + return destination + + +if IS_PYDANTIC_V2: + + class V2RootModel(UniversalBaseModel, pydantic.RootModel): # type: ignore[misc, name-defined, type-arg] + pass + + UniversalRootModel: TypeAlias = V2RootModel # type: ignore[misc] +else: + UniversalRootModel: TypeAlias = UniversalBaseModel # type: ignore[misc, no-redef] + + +def encode_by_type(o: Any) -> Any: + encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(tuple) + for type_, encoder in encoders_by_type.items(): + encoders_by_class_tuples[encoder] += (type_,) + + if type(o) in encoders_by_type: + return encoders_by_type[type(o)](o) + for encoder, classes_tuple in encoders_by_class_tuples.items(): + if isinstance(o, classes_tuple): + return encoder(o) + + +def update_forward_refs(model: Type["Model"], **localns: Any) -> None: + if IS_PYDANTIC_V2: + model.model_rebuild(raise_errors=False) # type: ignore[attr-defined] + else: + model.update_forward_refs(**localns) + + +# Mirrors Pydantic's internal typing +AnyCallable = Callable[..., Any] + + +def universal_root_validator( + pre: bool = False, +) -> Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + if IS_PYDANTIC_V2: + # In Pydantic v2, for RootModel we always use "before" mode + # The custom validators transform the input value before the model is created + return cast(AnyCallable, pydantic.model_validator(mode="before")(func)) # type: ignore[attr-defined] + return cast(AnyCallable, pydantic.root_validator(pre=pre)(func)) # type: ignore[call-overload] + + return decorator + + +def universal_field_validator(field_name: str, pre: bool = False) -> Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + if IS_PYDANTIC_V2: + return cast(AnyCallable, pydantic.field_validator(field_name, mode="before" if pre else "after")(func)) # type: ignore[attr-defined] + return cast(AnyCallable, pydantic.validator(field_name, pre=pre)(func)) + + return decorator + + +PydanticField = Union[ModelField, _FieldInfo] + + +def _get_model_fields(model: Type["Model"]) -> Mapping[str, PydanticField]: + if IS_PYDANTIC_V2: + return cast(Mapping[str, PydanticField], model.model_fields) # type: ignore[attr-defined] + return cast(Mapping[str, PydanticField], model.__fields__) + + +def _get_field_default(field: PydanticField) -> Any: + try: + value = field.get_default() # type: ignore[union-attr] + except: + value = field.default + if IS_PYDANTIC_V2: + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + return value diff --git a/src/roamhq/core/query_encoder.py b/src/roamhq/core/query_encoder.py new file mode 100644 index 0000000..11b962b --- /dev/null +++ b/src/roamhq/core/query_encoder.py @@ -0,0 +1,60 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +import pydantic + + +# Flattens dicts to be of the form {"key[subkey][subkey2]": value} where value is not a dict +def traverse_query_dict(dict_flat: Dict[str, Any], key_prefix: Optional[str] = None) -> List[Tuple[str, Any]]: + result = [] + for k, v in dict_flat.items(): + key = f"{key_prefix}[{k}]" if key_prefix is not None else k + if isinstance(v, dict): + result.extend(traverse_query_dict(v, key)) + elif isinstance(v, list): + for arr_v in v: + if isinstance(arr_v, dict): + result.extend(traverse_query_dict(arr_v, key)) + else: + result.append((key, arr_v)) + else: + result.append((key, v)) + return result + + +def single_query_encoder(query_key: str, query_value: Any) -> List[Tuple[str, Any]]: + if isinstance(query_value, pydantic.BaseModel) or isinstance(query_value, dict): + if isinstance(query_value, pydantic.BaseModel): + obj_dict = query_value.dict(by_alias=True) + else: + obj_dict = query_value + return traverse_query_dict(obj_dict, query_key) + elif isinstance(query_value, list): + encoded_values: List[Tuple[str, Any]] = [] + for value in query_value: + if isinstance(value, pydantic.BaseModel) or isinstance(value, dict): + if isinstance(value, pydantic.BaseModel): + obj_dict = value.dict(by_alias=True) + elif isinstance(value, dict): + obj_dict = value + + encoded_values.extend(single_query_encoder(query_key, obj_dict)) + else: + encoded_values.append((query_key, value)) + + return encoded_values + + return [(query_key, query_value)] + + +def encode_query(query: Optional[Dict[str, Any]]) -> Optional[List[Tuple[str, Any]]]: + if query is None: + return None + + encoded_query = [] + for k, v in query.items(): + encoded_query.extend(single_query_encoder(k, v)) + return encoded_query diff --git a/src/roamhq/core/remove_none_from_dict.py b/src/roamhq/core/remove_none_from_dict.py new file mode 100644 index 0000000..9e5c154 --- /dev/null +++ b/src/roamhq/core/remove_none_from_dict.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional + + +def remove_none_from_dict(original: Mapping[str, Optional[Any]]) -> Dict[str, Any]: + new: Dict[str, Any] = {} + for key, value in original.items(): + if value is not None: + new[key] = value + return new diff --git a/src/roamhq/core/request_options.py b/src/roamhq/core/request_options.py new file mode 100644 index 0000000..3717e6c --- /dev/null +++ b/src/roamhq/core/request_options.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +try: + from typing import NotRequired # type: ignore +except ImportError: + from typing_extensions import NotRequired + + +class RequestOptions(typing.TypedDict, total=False): + """ + Additional options for request-specific configuration when calling APIs via the SDK. + This is used primarily as an optional final parameter for service functions. + + Attributes: + - timeout: float. The number of seconds to await an API call before timing out. + + - timeout_in_seconds: int. Deprecated alias for `timeout`; both are in seconds. Prefer `timeout`. + + - max_retries: int. The max number of retries to attempt if the API call fails. + + - additional_headers: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's header dict + + - additional_query_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's query parameters dict + + - additional_body_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's body parameters dict + + - chunk_size: int. The size, in bytes, to process each chunk of data being streamed back within the response. This equates to leveraging `chunk_size` within `requests` or `httpx`, and is only leveraged for file downloads. + """ + + timeout: NotRequired[float] + timeout_in_seconds: NotRequired[int] + max_retries: NotRequired[int] + additional_headers: NotRequired[typing.Dict[str, typing.Any]] + additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]] + additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]] + chunk_size: NotRequired[int] + stream_reconnection_enabled: NotRequired[bool] + max_stream_reconnection_attempts: NotRequired[int] diff --git a/src/roamhq/core/serialization.py b/src/roamhq/core/serialization.py new file mode 100644 index 0000000..9d64ad6 --- /dev/null +++ b/src/roamhq/core/serialization.py @@ -0,0 +1,349 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import collections +import inspect +import typing + +import pydantic +import typing_extensions + + +class FieldMetadata: + """ + Metadata class used to annotate fields to provide additional information. + + Example: + class MyDict(TypedDict): + field: typing.Annotated[str, FieldMetadata(alias="field_name")] + + Will serialize: `{"field": "value"}` + To: `{"field_name": "value"}` + """ + + alias: str + + def __init__(self, *, alias: str) -> None: + self.alias = alias + + +# Resolving type hints (typing.get_type_hints) is expensive because it eval/compiles +# forward-reference annotations. The result is constant for a given type, so we cache it. +# This is critical for hot paths like SSE event parsing, where the same (often large +# discriminated-union) type is converted on every single event. +_type_hints_cache: typing.Dict[typing.Any, typing.Dict[str, typing.Any]] = {} + + +def _get_cached_type_hints(expected_type: typing.Any) -> typing.Dict[str, typing.Any]: + try: + cached = _type_hints_cache.get(expected_type) + except TypeError: + # Unhashable type; resolve without caching. + return _resolve_type_hints(expected_type) + if cached is None: + cached = _resolve_type_hints(expected_type) + _type_hints_cache[expected_type] = cached + return cached + + +def _resolve_type_hints(expected_type: typing.Any) -> typing.Dict[str, typing.Any]: + try: + return typing_extensions.get_type_hints(expected_type, include_extras=True) + except NameError: + # The type contains a circular reference, so we use the __annotations__ attribute directly. + return getattr(expected_type, "__annotations__", {}) + + +# Whether convert_and_respect_annotation_metadata can possibly rewrite anything for a given +# annotation, i.e. whether any reachable model/TypedDict field carries a FieldMetadata alias. +# This is constant per type, so we cache it and use it to short-circuit the recursive walk. +_requires_conversion_cache: typing.Dict[typing.Any, bool] = {} + + +def _requires_conversion(type_: typing.Any) -> bool: + try: + cached = _requires_conversion_cache.get(type_) + except TypeError: + # Unhashable annotation; compute without caching. + return _compute_requires_conversion(type_, set()) + if cached is None: + cached = _compute_requires_conversion(type_, set()) + _requires_conversion_cache[type_] = cached + return cached + + +def _compute_requires_conversion(type_: typing.Any, seen: typing.Set[typing.Any]) -> bool: + clean_type = _remove_annotations(type_) + + try: + if clean_type in seen: + return False + seen = seen | {clean_type} + except TypeError: + # Unhashable type; skip cycle tracking (the type graph is finite in practice). + pass + + # Models / TypedDicts: a field alias here means we must dealias; otherwise recurse into fields. + if (inspect.isclass(clean_type) and issubclass(clean_type, pydantic.BaseModel)) or typing_extensions.is_typeddict( + clean_type + ): + annotations = _get_cached_type_hints(clean_type) + if _get_alias_to_field_name(annotations): + return True + return any(_compute_requires_conversion(hint, seen) for hint in annotations.values()) + + # Containers / unions: recurse into the type arguments (List/Set/Sequence/Dict/Union/etc.). + return any(_compute_requires_conversion(arg, seen) for arg in typing_extensions.get_args(clean_type)) + + +def convert_and_respect_annotation_metadata( + *, + object_: typing.Any, + annotation: typing.Any, + inner_type: typing.Optional[typing.Any] = None, + direction: typing.Literal["read", "write"], +) -> typing.Any: + """ + Respect the metadata annotations on a field, such as aliasing. This function effectively + manipulates the dict-form of an object to respect the metadata annotations. This is primarily used for + TypedDicts, which cannot support aliasing out of the box, and can be extended for additional + utilities, such as defaults. + + Parameters + ---------- + object_ : typing.Any + + annotation : type + The type we're looking to apply typing annotations from + + inner_type : typing.Optional[type] + + Returns + ------- + typing.Any + """ + + if object_ is None: + return None + if inner_type is None: + inner_type = annotation + # The only thing this function ever rewrites is keys that carry a FieldMetadata + # alias. If nothing in the (cached) type graph has such an alias, the conversion is + # a content-identity transform, so we can skip the entire recursive walk. This is + # the hot path for SSE streaming, where a large discriminated union would otherwise + # be traversed on every single event. + if not _requires_conversion(annotation): + return object_ + + clean_type = _remove_annotations(inner_type) + # Pydantic models + if ( + inspect.isclass(clean_type) + and issubclass(clean_type, pydantic.BaseModel) + and isinstance(object_, typing.Mapping) + ): + return _convert_mapping(object_, clean_type, direction) + # TypedDicts + if typing_extensions.is_typeddict(clean_type) and isinstance(object_, typing.Mapping): + return _convert_mapping(object_, clean_type, direction) + + if ( + typing_extensions.get_origin(clean_type) == typing.Dict + or typing_extensions.get_origin(clean_type) == dict + or clean_type == typing.Dict + ) and isinstance(object_, typing.Dict): + key_type = typing_extensions.get_args(clean_type)[0] + value_type = typing_extensions.get_args(clean_type)[1] + + return { + key: convert_and_respect_annotation_metadata( + object_=value, + annotation=annotation, + inner_type=value_type, + direction=direction, + ) + for key, value in object_.items() + } + + # If you're iterating on a string, do not bother to coerce it to a sequence. + if not isinstance(object_, str): + if ( + typing_extensions.get_origin(clean_type) == typing.Set + or typing_extensions.get_origin(clean_type) == set + or clean_type == typing.Set + ) and isinstance(object_, typing.Set): + inner_type = typing_extensions.get_args(clean_type)[0] + return { + convert_and_respect_annotation_metadata( + object_=item, + annotation=annotation, + inner_type=inner_type, + direction=direction, + ) + for item in object_ + } + elif ( + ( + typing_extensions.get_origin(clean_type) == typing.List + or typing_extensions.get_origin(clean_type) == list + or clean_type == typing.List + ) + and isinstance(object_, typing.List) + ) or ( + ( + typing_extensions.get_origin(clean_type) == typing.Sequence + or typing_extensions.get_origin(clean_type) == collections.abc.Sequence + or clean_type == typing.Sequence + ) + and isinstance(object_, typing.Sequence) + ): + inner_type = typing_extensions.get_args(clean_type)[0] + return [ + convert_and_respect_annotation_metadata( + object_=item, + annotation=annotation, + inner_type=inner_type, + direction=direction, + ) + for item in object_ + ] + + if typing_extensions.get_origin(clean_type) == typing.Union: + # We should be able to ~relatively~ safely try to convert keys against all + # member types in the union, the edge case here is if one member aliases a field + # of the same name to a different name from another member + # Or if another member aliases a field of the same name that another member does not. + for member in typing_extensions.get_args(clean_type): + object_ = convert_and_respect_annotation_metadata( + object_=object_, + annotation=annotation, + inner_type=member, + direction=direction, + ) + return object_ + + annotated_type = _get_annotation(annotation) + if annotated_type is None: + return object_ + + # If the object is not a TypedDict, a Union, or other container (list, set, sequence, etc.) + # Then we can safely call it on the recursive conversion. + return object_ + + +def _convert_mapping( + object_: typing.Mapping[str, object], + expected_type: typing.Any, + direction: typing.Literal["read", "write"], +) -> typing.Mapping[str, object]: + converted_object: typing.Dict[str, object] = {} + annotations = _get_cached_type_hints(expected_type) + aliases_to_field_names = _get_alias_to_field_name(annotations) + for key, value in object_.items(): + if direction == "read" and key in aliases_to_field_names: + dealiased_key = aliases_to_field_names.get(key) + if dealiased_key is not None: + type_ = annotations.get(dealiased_key) + else: + type_ = annotations.get(key) + # Note you can't get the annotation by the field name if you're in read mode, so you must check the aliases map + # + # So this is effectively saying if we're in write mode, and we don't have a type, or if we're in read mode and we don't have an alias + # then we can just pass the value through as is + if type_ is None: + converted_object[key] = value + elif direction == "read" and key not in aliases_to_field_names: + converted_object[key] = convert_and_respect_annotation_metadata( + object_=value, annotation=type_, direction=direction + ) + else: + converted_object[_alias_key(key, type_, direction, aliases_to_field_names)] = ( + convert_and_respect_annotation_metadata(object_=value, annotation=type_, direction=direction) + ) + return converted_object + + +def _get_annotation(type_: typing.Any) -> typing.Optional[typing.Any]: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return None + + if maybe_annotated_type == typing_extensions.NotRequired: + type_ = typing_extensions.get_args(type_)[0] + maybe_annotated_type = typing_extensions.get_origin(type_) + + if maybe_annotated_type == typing_extensions.Annotated: + return type_ + + return None + + +def _remove_annotations(type_: typing.Any) -> typing.Any: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return type_ + + if maybe_annotated_type == typing_extensions.NotRequired: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + if maybe_annotated_type == typing_extensions.Annotated: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + return type_ + + +def get_alias_to_field_mapping(type_: typing.Any) -> typing.Dict[str, str]: + annotations = _get_cached_type_hints(type_) + return _get_alias_to_field_name(annotations) + + +def get_field_to_alias_mapping(type_: typing.Any) -> typing.Dict[str, str]: + annotations = _get_cached_type_hints(type_) + return _get_field_to_alias_name(annotations) + + +def _get_alias_to_field_name( + field_to_hint: typing.Dict[str, typing.Any], +) -> typing.Dict[str, str]: + aliases = {} + for field, hint in field_to_hint.items(): + maybe_alias = _get_alias_from_type(hint) + if maybe_alias is not None: + aliases[maybe_alias] = field + return aliases + + +def _get_field_to_alias_name( + field_to_hint: typing.Dict[str, typing.Any], +) -> typing.Dict[str, str]: + aliases = {} + for field, hint in field_to_hint.items(): + maybe_alias = _get_alias_from_type(hint) + if maybe_alias is not None: + aliases[field] = maybe_alias + return aliases + + +def _get_alias_from_type(type_: typing.Any) -> typing.Optional[str]: + maybe_annotated_type = _get_annotation(type_) + + if maybe_annotated_type is not None: + # The actual annotations are 1 onward, the first is the annotated type + annotations = typing_extensions.get_args(maybe_annotated_type)[1:] + + for annotation in annotations: + if isinstance(annotation, FieldMetadata) and annotation.alias is not None: + return annotation.alias + return None + + +def _alias_key( + key: str, + type_: typing.Any, + direction: typing.Literal["read", "write"], + aliases_to_field_names: typing.Dict[str, str], +) -> str: + if direction == "read": + return aliases_to_field_names.get(key, key) + return _get_alias_from_type(type_=type_) or key diff --git a/src/roamhq/environment.py b/src/roamhq/environment.py new file mode 100644 index 0000000..2f123e8 --- /dev/null +++ b/src/roamhq/environment.py @@ -0,0 +1,9 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import enum + + +class RoamClientEnvironment(enum.Enum): + DEFAULT = "https://api.ro.am/v1" diff --git a/src/roamhq/errors/__init__.py b/src/roamhq/errors/__init__.py new file mode 100644 index 0000000..17416e7 --- /dev/null +++ b/src/roamhq/errors/__init__.py @@ -0,0 +1,67 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .bad_request_error import BadRequestError + from .conflict_error import ConflictError + from .content_too_large_error import ContentTooLargeError + from .forbidden_error import ForbiddenError + from .internal_server_error import InternalServerError + from .method_not_allowed_error import MethodNotAllowedError + from .not_found_error import NotFoundError + from .too_many_requests_error import TooManyRequestsError + from .unauthorized_error import UnauthorizedError + from .unsupported_media_type_error import UnsupportedMediaTypeError +_dynamic_imports: typing.Dict[str, str] = { + "BadRequestError": ".bad_request_error", + "ConflictError": ".conflict_error", + "ContentTooLargeError": ".content_too_large_error", + "ForbiddenError": ".forbidden_error", + "InternalServerError": ".internal_server_error", + "MethodNotAllowedError": ".method_not_allowed_error", + "NotFoundError": ".not_found_error", + "TooManyRequestsError": ".too_many_requests_error", + "UnauthorizedError": ".unauthorized_error", + "UnsupportedMediaTypeError": ".unsupported_media_type_error", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "BadRequestError", + "ConflictError", + "ContentTooLargeError", + "ForbiddenError", + "InternalServerError", + "MethodNotAllowedError", + "NotFoundError", + "TooManyRequestsError", + "UnauthorizedError", + "UnsupportedMediaTypeError", +] diff --git a/src/roamhq/errors/bad_request_error.py b/src/roamhq/errors/bad_request_error.py new file mode 100644 index 0000000..9479ba6 --- /dev/null +++ b/src/roamhq/errors/bad_request_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class BadRequestError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=400, headers=headers, body=body) diff --git a/src/roamhq/errors/conflict_error.py b/src/roamhq/errors/conflict_error.py new file mode 100644 index 0000000..1b613f4 --- /dev/null +++ b/src/roamhq/errors/conflict_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class ConflictError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=409, headers=headers, body=body) diff --git a/src/roamhq/errors/content_too_large_error.py b/src/roamhq/errors/content_too_large_error.py new file mode 100644 index 0000000..bfa40dd --- /dev/null +++ b/src/roamhq/errors/content_too_large_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class ContentTooLargeError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=413, headers=headers, body=body) diff --git a/src/roamhq/errors/forbidden_error.py b/src/roamhq/errors/forbidden_error.py new file mode 100644 index 0000000..613b80c --- /dev/null +++ b/src/roamhq/errors/forbidden_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class ForbiddenError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=403, headers=headers, body=body) diff --git a/src/roamhq/errors/internal_server_error.py b/src/roamhq/errors/internal_server_error.py new file mode 100644 index 0000000..b587d10 --- /dev/null +++ b/src/roamhq/errors/internal_server_error.py @@ -0,0 +1,12 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError + + +class InternalServerError(ApiError): + def __init__(self, body: typing.Any, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=500, headers=headers, body=body) diff --git a/src/roamhq/errors/method_not_allowed_error.py b/src/roamhq/errors/method_not_allowed_error.py new file mode 100644 index 0000000..72d71f6 --- /dev/null +++ b/src/roamhq/errors/method_not_allowed_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class MethodNotAllowedError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=405, headers=headers, body=body) diff --git a/src/roamhq/errors/not_found_error.py b/src/roamhq/errors/not_found_error.py new file mode 100644 index 0000000..147f00c --- /dev/null +++ b/src/roamhq/errors/not_found_error.py @@ -0,0 +1,12 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError + + +class NotFoundError(ApiError): + def __init__(self, body: typing.Any, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=404, headers=headers, body=body) diff --git a/src/roamhq/errors/too_many_requests_error.py b/src/roamhq/errors/too_many_requests_error.py new file mode 100644 index 0000000..9c5162c --- /dev/null +++ b/src/roamhq/errors/too_many_requests_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class TooManyRequestsError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=429, headers=headers, body=body) diff --git a/src/roamhq/errors/unauthorized_error.py b/src/roamhq/errors/unauthorized_error.py new file mode 100644 index 0000000..ffd2299 --- /dev/null +++ b/src/roamhq/errors/unauthorized_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class UnauthorizedError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=401, headers=headers, body=body) diff --git a/src/roamhq/errors/unsupported_media_type_error.py b/src/roamhq/errors/unsupported_media_type_error.py new file mode 100644 index 0000000..2902924 --- /dev/null +++ b/src/roamhq/errors/unsupported_media_type_error.py @@ -0,0 +1,13 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.api_error import ApiError +from ..types.error import Error + + +class UnsupportedMediaTypeError(ApiError): + def __init__(self, body: Error, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=415, headers=headers, body=body) diff --git a/src/roamhq/group/__init__.py b/src/roamhq/group/__init__.py new file mode 100644 index 0000000..f7638cb --- /dev/null +++ b/src/roamhq/group/__init__.py @@ -0,0 +1,66 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + AddGroupRequestMembersItem, + AddGroupRequestMembersItemRole, + CreateGroupRequestMembersItem, + CreateGroupRequestMembersItemRole, + ListGroupResponse, + ListGroupResponseGroupsItem, + ListGroupResponseGroupsItemAccessMode, + ListGroupResponseGroupsItemType, + MembersGroupResponse, + ) +_dynamic_imports: typing.Dict[str, str] = { + "AddGroupRequestMembersItem": ".types", + "AddGroupRequestMembersItemRole": ".types", + "CreateGroupRequestMembersItem": ".types", + "CreateGroupRequestMembersItemRole": ".types", + "ListGroupResponse": ".types", + "ListGroupResponseGroupsItem": ".types", + "ListGroupResponseGroupsItemAccessMode": ".types", + "ListGroupResponseGroupsItemType": ".types", + "MembersGroupResponse": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "AddGroupRequestMembersItem", + "AddGroupRequestMembersItemRole", + "CreateGroupRequestMembersItem", + "CreateGroupRequestMembersItemRole", + "ListGroupResponse", + "ListGroupResponseGroupsItem", + "ListGroupResponseGroupsItemAccessMode", + "ListGroupResponseGroupsItemType", + "MembersGroupResponse", +] diff --git a/src/roamhq/group/client.py b/src/roamhq/group/client.py new file mode 100644 index 0000000..7b95267 --- /dev/null +++ b/src/roamhq/group/client.py @@ -0,0 +1,1067 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.group import Group +from .raw_client import AsyncRawGroupClient, RawGroupClient +from .types.add_group_request_members_item import AddGroupRequestMembersItem +from .types.create_group_request_members_item import CreateGroupRequestMembersItem +from .types.list_group_response import ListGroupResponse +from .types.members_group_response import MembersGroupResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class GroupClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawGroupClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawGroupClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawGroupClient + """ + return self._raw_client + + def list( + self, + *, + query: typing.Optional[str] = None, + type: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListGroupResponse: + """ + Lists non-archived groups accessible to the caller. + + Filter by name with `query` (ranked text match), restrict by group + type with `type`, and paginate with `limit` / `cursor`. + + **Access:** Organization and Personal. + + **Required scope:** `group:read` + + Parameters + ---------- + query : typing.Optional[str] + Text filter. Groups are ranked by how well their name matches the query. + + type : typing.Optional[str] + Comma-separated list of group types to include. Must be one or + more of `standard`, `magicast`, `meeting`, `roam`, `onair`. + Defaults to all types. + + limit : typing.Optional[int] + Number of groups to return per page (default 50, max 100). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListGroupResponse + Groups retrieved successfully. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.list() + """ + _response = self._raw_client.list( + query=query, type=type, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + def info( + self, + *, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> Group: + """ + Get information about a specific group by its ID or name. + + Provide either `id` or `name`, not both. + + **Required scope:** `group:read` + + Parameters + ---------- + id : typing.Optional[str] + The group's ID. Mutually exclusive with `name`. + + name : typing.Optional[str] + The group's name. Mutually exclusive with `id`. Returns first match if multiple groups have the same name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Group + Group info retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.info() + """ + _response = self._raw_client.info(id=id, name=name, request_options=request_options) + return _response.data + + def create( + self, + *, + name: str, + members: typing.Sequence[CreateGroupRequestMembersItem], + description: typing.Optional[str] = OMIT, + private: typing.Optional[bool] = OMIT, + enforce_threads: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Group: + """ + Create a group chat. + + Groups which specify at least one admin will operate in an "Admin only" management + mode, where only admins may change settings. Otherwise, all members have + that capability. + + Groups require at least one member. Users can be specified by user ID or email address. + + **Required scope:** `group:write` + + Parameters + ---------- + name : str + Name of the group + + members : typing.Sequence[CreateGroupRequestMembersItem] + Group members with their roles + + description : typing.Optional[str] + Description of the group + + private : typing.Optional[bool] + Whether the group is private (default false) + + enforce_threads : typing.Optional[bool] + Whether to enforce threaded conversations + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Group + Group created successfully + + Examples + -------- + from roamhq import RoamClient + from roamhq.group import CreateGroupRequestMembersItem + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.create( + name="Engineering Team", + description="Group chat for engineering discussions and updates", + private=False, + enforce_threads=True, + members=[ + CreateGroupRequestMembersItem( + user_id="alex.chen@example.com", + role="member", + ), + CreateGroupRequestMembersItem( + user_id="taylor@example.com", + role="member", + ), + CreateGroupRequestMembersItem( + user_id="jordan.smith@example.com", + role="admin", + ), + ], + ) + """ + _response = self._raw_client.create( + name=name, + members=members, + description=description, + private=private, + enforce_threads=enforce_threads, + request_options=request_options, + ) + return _response.data + + def rename(self, *, id: str, name: str, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Rename a group by ID. + + Apps may only rename groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID + + name : str + The new name for the group + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.rename( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + name="Product Engineering", + ) + """ + _response = self._raw_client.rename(id=id, name=name, request_options=request_options) + return _response.data + + def archive(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Archive a group by ID. + + Apps may only archive groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID to archive. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.archive( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + ) + """ + _response = self._raw_client.archive(id=id, request_options=request_options) + return _response.data + + def members( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> MembersGroupResponse: + """ + List members in a group with their roles. + + Apps may list members if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + **Required scope:** `group:read` + + Every returned `userId` is a visible principal ID that resolves through + [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. Use + `user.list?ids` for ordered bulk hydration. + + Parameters + ---------- + id : str + Group ID. + + limit : typing.Optional[int] + The number of members to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + MembersGroupResponse + Members retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.members( + id="id", + ) + """ + _response = self._raw_client.members(id=id, limit=limit, cursor=cursor, request_options=request_options) + return _response.data + + def add( + self, + *, + id: str, + members: typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Add one or more group members with specified roles. + + Members can be specified by user ID or email address. Each member must be assigned a role (member or admin). + + Apps may add members to a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + If attempting to add an admin, the app must be an admin of the group. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] + List of members to add with their roles + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + from roamhq.group import AddGroupRequestMembersItem + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.add( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + members=[ + AddGroupRequestMembersItem( + user_id="709b8a57-70bc-427a-b6f0-b16ba5297f8c", + role="member", + ), + AddGroupRequestMembersItem( + user_id="f589a8cb-78ac-493e-8719-0fa8a22f65e0", + role="member", + ), + AddGroupRequestMembersItem( + user_id="af6663d5-0f37-4105-95df-4fea20ef7c7c", + role="admin", + ), + ], + ) + """ + _response = self._raw_client.add(id=id, members=members, request_options=request_options) + return _response.data + + def join(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> Group: + """ + Join a public group as the calling identity (Slack `conversations.join`). + + - Org tokens add the bot address as a member. + - Personal tokens add the **owner person**, never the PAT bot address. + - Private groups cannot be self-joined (`403`). + - Idempotent if the calling identity is already a member. + - Non-members of a group in another roam receive an opaque `403` + (`group_not_found`) — archived / type / privacy are not distinguished. + + Why join (webhooks vs history vs post): [Chat](https://developer.ro.am/docs/guides/chat). + + **Access:** Organization and Personal. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Group + Joined the group (or already a member) + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.join( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + ) + """ + _response = self._raw_client.join(id=id, request_options=request_options) + return _response.data + + def remove( + self, *, id: str, members: typing.Sequence[str], request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Remove one or more group members. + + Members can be specified by user ID or email address. + + Apps may remove members from a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + Removing members with the Admin role is not yet supported. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Sequence[str] + List of member IDs or email addresses to remove + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.group.remove( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + members=["709b8a57-70bc-427a-b6f0-b16ba5297f8c"], + ) + """ + _response = self._raw_client.remove(id=id, members=members, request_options=request_options) + return _response.data + + +class AsyncGroupClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawGroupClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawGroupClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawGroupClient + """ + return self._raw_client + + async def list( + self, + *, + query: typing.Optional[str] = None, + type: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListGroupResponse: + """ + Lists non-archived groups accessible to the caller. + + Filter by name with `query` (ranked text match), restrict by group + type with `type`, and paginate with `limit` / `cursor`. + + **Access:** Organization and Personal. + + **Required scope:** `group:read` + + Parameters + ---------- + query : typing.Optional[str] + Text filter. Groups are ranked by how well their name matches the query. + + type : typing.Optional[str] + Comma-separated list of group types to include. Must be one or + more of `standard`, `magicast`, `meeting`, `roam`, `onair`. + Defaults to all types. + + limit : typing.Optional[int] + Number of groups to return per page (default 50, max 100). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListGroupResponse + Groups retrieved successfully. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + query=query, type=type, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + async def info( + self, + *, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> Group: + """ + Get information about a specific group by its ID or name. + + Provide either `id` or `name`, not both. + + **Required scope:** `group:read` + + Parameters + ---------- + id : typing.Optional[str] + The group's ID. Mutually exclusive with `name`. + + name : typing.Optional[str] + The group's name. Mutually exclusive with `id`. Returns first match if multiple groups have the same name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Group + Group info retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.info() + + + asyncio.run(main()) + """ + _response = await self._raw_client.info(id=id, name=name, request_options=request_options) + return _response.data + + async def create( + self, + *, + name: str, + members: typing.Sequence[CreateGroupRequestMembersItem], + description: typing.Optional[str] = OMIT, + private: typing.Optional[bool] = OMIT, + enforce_threads: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Group: + """ + Create a group chat. + + Groups which specify at least one admin will operate in an "Admin only" management + mode, where only admins may change settings. Otherwise, all members have + that capability. + + Groups require at least one member. Users can be specified by user ID or email address. + + **Required scope:** `group:write` + + Parameters + ---------- + name : str + Name of the group + + members : typing.Sequence[CreateGroupRequestMembersItem] + Group members with their roles + + description : typing.Optional[str] + Description of the group + + private : typing.Optional[bool] + Whether the group is private (default false) + + enforce_threads : typing.Optional[bool] + Whether to enforce threaded conversations + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Group + Group created successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + from roamhq.group import CreateGroupRequestMembersItem + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.create( + name="Engineering Team", + description="Group chat for engineering discussions and updates", + private=False, + enforce_threads=True, + members=[ + CreateGroupRequestMembersItem( + user_id="alex.chen@example.com", + role="member", + ), + CreateGroupRequestMembersItem( + user_id="taylor@example.com", + role="member", + ), + CreateGroupRequestMembersItem( + user_id="jordan.smith@example.com", + role="admin", + ), + ], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create( + name=name, + members=members, + description=description, + private=private, + enforce_threads=enforce_threads, + request_options=request_options, + ) + return _response.data + + async def rename(self, *, id: str, name: str, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Rename a group by ID. + + Apps may only rename groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID + + name : str + The new name for the group + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.rename( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + name="Product Engineering", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.rename(id=id, name=name, request_options=request_options) + return _response.data + + async def archive(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Archive a group by ID. + + Apps may only archive groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID to archive. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.archive( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.archive(id=id, request_options=request_options) + return _response.data + + async def members( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> MembersGroupResponse: + """ + List members in a group with their roles. + + Apps may list members if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + **Required scope:** `group:read` + + Every returned `userId` is a visible principal ID that resolves through + [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. Use + `user.list?ids` for ordered bulk hydration. + + Parameters + ---------- + id : str + Group ID. + + limit : typing.Optional[int] + The number of members to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + MembersGroupResponse + Members retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.members( + id="id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.members(id=id, limit=limit, cursor=cursor, request_options=request_options) + return _response.data + + async def add( + self, + *, + id: str, + members: typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Add one or more group members with specified roles. + + Members can be specified by user ID or email address. Each member must be assigned a role (member or admin). + + Apps may add members to a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + If attempting to add an admin, the app must be an admin of the group. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] + List of members to add with their roles + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + from roamhq.group import AddGroupRequestMembersItem + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.add( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + members=[ + AddGroupRequestMembersItem( + user_id="709b8a57-70bc-427a-b6f0-b16ba5297f8c", + role="member", + ), + AddGroupRequestMembersItem( + user_id="f589a8cb-78ac-493e-8719-0fa8a22f65e0", + role="member", + ), + AddGroupRequestMembersItem( + user_id="af6663d5-0f37-4105-95df-4fea20ef7c7c", + role="admin", + ), + ], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.add(id=id, members=members, request_options=request_options) + return _response.data + + async def join(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> Group: + """ + Join a public group as the calling identity (Slack `conversations.join`). + + - Org tokens add the bot address as a member. + - Personal tokens add the **owner person**, never the PAT bot address. + - Private groups cannot be self-joined (`403`). + - Idempotent if the calling identity is already a member. + - Non-members of a group in another roam receive an opaque `403` + (`group_not_found`) — archived / type / privacy are not distinguished. + + Why join (webhooks vs history vs post): [Chat](https://developer.ro.am/docs/guides/chat). + + **Access:** Organization and Personal. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Group + Joined the group (or already a member) + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.join( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.join(id=id, request_options=request_options) + return _response.data + + async def remove( + self, *, id: str, members: typing.Sequence[str], request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Remove one or more group members. + + Members can be specified by user ID or email address. + + Apps may remove members from a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + Removing members with the Admin role is not yet supported. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Sequence[str] + List of member IDs or email addresses to remove + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.group.remove( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + members=["709b8a57-70bc-427a-b6f0-b16ba5297f8c"], + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.remove(id=id, members=members, request_options=request_options) + return _response.data diff --git a/src/roamhq/group/raw_client.py b/src/roamhq/group/raw_client.py new file mode 100644 index 0000000..4f425de --- /dev/null +++ b/src/roamhq/group/raw_client.py @@ -0,0 +1,2257 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..core.serialization import convert_and_respect_annotation_metadata +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from ..types.group import Group +from .types.add_group_request_members_item import AddGroupRequestMembersItem +from .types.create_group_request_members_item import CreateGroupRequestMembersItem +from .types.list_group_response import ListGroupResponse +from .types.members_group_response import MembersGroupResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawGroupClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, + *, + query: typing.Optional[str] = None, + type: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListGroupResponse]: + """ + Lists non-archived groups accessible to the caller. + + Filter by name with `query` (ranked text match), restrict by group + type with `type`, and paginate with `limit` / `cursor`. + + **Access:** Organization and Personal. + + **Required scope:** `group:read` + + Parameters + ---------- + query : typing.Optional[str] + Text filter. Groups are ranked by how well their name matches the query. + + type : typing.Optional[str] + Comma-separated list of group types to include. Must be one or + more of `standard`, `magicast`, `meeting`, `roam`, `onair`. + Defaults to all types. + + limit : typing.Optional[int] + Number of groups to return per page (default 50, max 100). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListGroupResponse] + Groups retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "group.list", + method="GET", + params={ + "query": query, + "type": type, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListGroupResponse, + parse_obj_as( + type_=ListGroupResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def info( + self, + *, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[Group]: + """ + Get information about a specific group by its ID or name. + + Provide either `id` or `name`, not both. + + **Required scope:** `group:read` + + Parameters + ---------- + id : typing.Optional[str] + The group's ID. Mutually exclusive with `name`. + + name : typing.Optional[str] + The group's name. Mutually exclusive with `id`. Returns first match if multiple groups have the same name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[Group] + Group info retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "group.info", + method="GET", + params={ + "id": id, + "name": name, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Group, + parse_obj_as( + type_=Group, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create( + self, + *, + name: str, + members: typing.Sequence[CreateGroupRequestMembersItem], + description: typing.Optional[str] = OMIT, + private: typing.Optional[bool] = OMIT, + enforce_threads: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[Group]: + """ + Create a group chat. + + Groups which specify at least one admin will operate in an "Admin only" management + mode, where only admins may change settings. Otherwise, all members have + that capability. + + Groups require at least one member. Users can be specified by user ID or email address. + + **Required scope:** `group:write` + + Parameters + ---------- + name : str + Name of the group + + members : typing.Sequence[CreateGroupRequestMembersItem] + Group members with their roles + + description : typing.Optional[str] + Description of the group + + private : typing.Optional[bool] + Whether the group is private (default false) + + enforce_threads : typing.Optional[bool] + Whether to enforce threaded conversations + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[Group] + Group created successfully + """ + _response = self._client_wrapper.httpx_client.request( + "group.create", + method="POST", + json={ + "name": name, + "description": description, + "private": private, + "enforceThreads": enforce_threads, + "members": convert_and_respect_annotation_metadata( + object_=members, annotation=typing.Sequence[CreateGroupRequestMembersItem], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Group, + parse_obj_as( + type_=Group, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def rename( + self, *, id: str, name: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[None]: + """ + Rename a group by ID. + + Apps may only rename groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID + + name : str + The new name for the group + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "group.rename", + method="POST", + json={ + "id": id, + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def archive(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: + """ + Archive a group by ID. + + Apps may only archive groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID to archive. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "group.archive", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def members( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[MembersGroupResponse]: + """ + List members in a group with their roles. + + Apps may list members if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + **Required scope:** `group:read` + + Every returned `userId` is a visible principal ID that resolves through + [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. Use + `user.list?ids` for ordered bulk hydration. + + Parameters + ---------- + id : str + Group ID. + + limit : typing.Optional[int] + The number of members to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[MembersGroupResponse] + Members retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "group.members", + method="GET", + params={ + "id": id, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + MembersGroupResponse, + parse_obj_as( + type_=MembersGroupResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def add( + self, + *, + id: str, + members: typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[None]: + """ + Add one or more group members with specified roles. + + Members can be specified by user ID or email address. Each member must be assigned a role (member or admin). + + Apps may add members to a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + If attempting to add an admin, the app must be an admin of the group. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] + List of members to add with their roles + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "group.add", + method="POST", + json={ + "id": id, + "members": convert_and_respect_annotation_metadata( + object_=members, annotation=typing.Sequence[AddGroupRequestMembersItem], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def join(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[Group]: + """ + Join a public group as the calling identity (Slack `conversations.join`). + + - Org tokens add the bot address as a member. + - Personal tokens add the **owner person**, never the PAT bot address. + - Private groups cannot be self-joined (`403`). + - Idempotent if the calling identity is already a member. + - Non-members of a group in another roam receive an opaque `403` + (`group_not_found`) — archived / type / privacy are not distinguished. + + Why join (webhooks vs history vs post): [Chat](https://developer.ro.am/docs/guides/chat). + + **Access:** Organization and Personal. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[Group] + Joined the group (or already a member) + """ + _response = self._client_wrapper.httpx_client.request( + "group.join", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Group, + parse_obj_as( + type_=Group, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def remove( + self, *, id: str, members: typing.Sequence[str], request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[None]: + """ + Remove one or more group members. + + Members can be specified by user ID or email address. + + Apps may remove members from a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + Removing members with the Admin role is not yet supported. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Sequence[str] + List of member IDs or email addresses to remove + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "group.remove", + method="POST", + json={ + "id": id, + "members": members, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawGroupClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, + *, + query: typing.Optional[str] = None, + type: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListGroupResponse]: + """ + Lists non-archived groups accessible to the caller. + + Filter by name with `query` (ranked text match), restrict by group + type with `type`, and paginate with `limit` / `cursor`. + + **Access:** Organization and Personal. + + **Required scope:** `group:read` + + Parameters + ---------- + query : typing.Optional[str] + Text filter. Groups are ranked by how well their name matches the query. + + type : typing.Optional[str] + Comma-separated list of group types to include. Must be one or + more of `standard`, `magicast`, `meeting`, `roam`, `onair`. + Defaults to all types. + + limit : typing.Optional[int] + Number of groups to return per page (default 50, max 100). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListGroupResponse] + Groups retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "group.list", + method="GET", + params={ + "query": query, + "type": type, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListGroupResponse, + parse_obj_as( + type_=ListGroupResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def info( + self, + *, + id: typing.Optional[str] = None, + name: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[Group]: + """ + Get information about a specific group by its ID or name. + + Provide either `id` or `name`, not both. + + **Required scope:** `group:read` + + Parameters + ---------- + id : typing.Optional[str] + The group's ID. Mutually exclusive with `name`. + + name : typing.Optional[str] + The group's name. Mutually exclusive with `id`. Returns first match if multiple groups have the same name. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[Group] + Group info retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "group.info", + method="GET", + params={ + "id": id, + "name": name, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Group, + parse_obj_as( + type_=Group, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create( + self, + *, + name: str, + members: typing.Sequence[CreateGroupRequestMembersItem], + description: typing.Optional[str] = OMIT, + private: typing.Optional[bool] = OMIT, + enforce_threads: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[Group]: + """ + Create a group chat. + + Groups which specify at least one admin will operate in an "Admin only" management + mode, where only admins may change settings. Otherwise, all members have + that capability. + + Groups require at least one member. Users can be specified by user ID or email address. + + **Required scope:** `group:write` + + Parameters + ---------- + name : str + Name of the group + + members : typing.Sequence[CreateGroupRequestMembersItem] + Group members with their roles + + description : typing.Optional[str] + Description of the group + + private : typing.Optional[bool] + Whether the group is private (default false) + + enforce_threads : typing.Optional[bool] + Whether to enforce threaded conversations + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[Group] + Group created successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "group.create", + method="POST", + json={ + "name": name, + "description": description, + "private": private, + "enforceThreads": enforce_threads, + "members": convert_and_respect_annotation_metadata( + object_=members, annotation=typing.Sequence[CreateGroupRequestMembersItem], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Group, + parse_obj_as( + type_=Group, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def rename( + self, *, id: str, name: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: + """ + Rename a group by ID. + + Apps may only rename groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID + + name : str + The new name for the group + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "group.rename", + method="POST", + json={ + "id": id, + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def archive( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: + """ + Archive a group by ID. + + Apps may only archive groups for which they are an admin. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + The group ID to archive. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "group.archive", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def members( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[MembersGroupResponse]: + """ + List members in a group with their roles. + + Apps may list members if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + **Required scope:** `group:read` + + Every returned `userId` is a visible principal ID that resolves through + [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. Use + `user.list?ids` for ordered bulk hydration. + + Parameters + ---------- + id : str + Group ID. + + limit : typing.Optional[int] + The number of members to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[MembersGroupResponse] + Members retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "group.members", + method="GET", + params={ + "id": id, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + MembersGroupResponse, + parse_obj_as( + type_=MembersGroupResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def add( + self, + *, + id: str, + members: typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[None]: + """ + Add one or more group members with specified roles. + + Members can be specified by user ID or email address. Each member must be assigned a role (member or admin). + + Apps may add members to a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + If attempting to add an admin, the app must be an admin of the group. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Optional[typing.Sequence[AddGroupRequestMembersItem]] + List of members to add with their roles + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "group.add", + method="POST", + json={ + "id": id, + "members": convert_and_respect_annotation_metadata( + object_=members, annotation=typing.Sequence[AddGroupRequestMembersItem], direction="write" + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def join( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[Group]: + """ + Join a public group as the calling identity (Slack `conversations.join`). + + - Org tokens add the bot address as a member. + - Personal tokens add the **owner person**, never the PAT bot address. + - Private groups cannot be self-joined (`403`). + - Idempotent if the calling identity is already a member. + - Non-members of a group in another roam receive an opaque `403` + (`group_not_found`) — archived / type / privacy are not distinguished. + + Why join (webhooks vs history vs post): [Chat](https://developer.ro.am/docs/guides/chat). + + **Access:** Organization and Personal. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[Group] + Joined the group (or already a member) + """ + _response = await self._client_wrapper.httpx_client.request( + "group.join", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Group, + parse_obj_as( + type_=Group, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def remove( + self, *, id: str, members: typing.Sequence[str], request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: + """ + Remove one or more group members. + + Members can be specified by user ID or email address. + + Apps may remove members from a group if one of the following conditions is true: + 1. It is a public group in their Roam. + 2. They are a member of the group. + + Removing members with the Admin role is not yet supported. + + **Required scope:** `group:write` + + Parameters + ---------- + id : str + Group ID + + members : typing.Sequence[str] + List of member IDs or email addresses to remove + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "group.remove", + method="POST", + json={ + "id": id, + "members": members, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/group/types/__init__.py b/src/roamhq/group/types/__init__.py new file mode 100644 index 0000000..9e0c088 --- /dev/null +++ b/src/roamhq/group/types/__init__.py @@ -0,0 +1,64 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .add_group_request_members_item import AddGroupRequestMembersItem + from .add_group_request_members_item_role import AddGroupRequestMembersItemRole + from .create_group_request_members_item import CreateGroupRequestMembersItem + from .create_group_request_members_item_role import CreateGroupRequestMembersItemRole + from .list_group_response import ListGroupResponse + from .list_group_response_groups_item import ListGroupResponseGroupsItem + from .list_group_response_groups_item_access_mode import ListGroupResponseGroupsItemAccessMode + from .list_group_response_groups_item_type import ListGroupResponseGroupsItemType + from .members_group_response import MembersGroupResponse +_dynamic_imports: typing.Dict[str, str] = { + "AddGroupRequestMembersItem": ".add_group_request_members_item", + "AddGroupRequestMembersItemRole": ".add_group_request_members_item_role", + "CreateGroupRequestMembersItem": ".create_group_request_members_item", + "CreateGroupRequestMembersItemRole": ".create_group_request_members_item_role", + "ListGroupResponse": ".list_group_response", + "ListGroupResponseGroupsItem": ".list_group_response_groups_item", + "ListGroupResponseGroupsItemAccessMode": ".list_group_response_groups_item_access_mode", + "ListGroupResponseGroupsItemType": ".list_group_response_groups_item_type", + "MembersGroupResponse": ".members_group_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "AddGroupRequestMembersItem", + "AddGroupRequestMembersItemRole", + "CreateGroupRequestMembersItem", + "CreateGroupRequestMembersItemRole", + "ListGroupResponse", + "ListGroupResponseGroupsItem", + "ListGroupResponseGroupsItemAccessMode", + "ListGroupResponseGroupsItemType", + "MembersGroupResponse", +] diff --git a/src/roamhq/group/types/add_group_request_members_item.py b/src/roamhq/group/types/add_group_request_members_item.py new file mode 100644 index 0000000..cb63e2d --- /dev/null +++ b/src/roamhq/group/types/add_group_request_members_item.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .add_group_request_members_item_role import AddGroupRequestMembersItemRole + + +class AddGroupRequestMembersItem(UniversalBaseModel): + user_id: typing_extensions.Annotated[ + str, FieldMetadata(alias="userId"), pydantic.Field(alias="userId", description="User ID or email address") + ] + """ + User ID or email address + """ + + role: AddGroupRequestMembersItemRole = pydantic.Field() + """ + Role for this member + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/group/types/add_group_request_members_item_role.py b/src/roamhq/group/types/add_group_request_members_item_role.py new file mode 100644 index 0000000..9ffdc13 --- /dev/null +++ b/src/roamhq/group/types/add_group_request_members_item_role.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +AddGroupRequestMembersItemRole = typing.Union[typing.Literal["member", "admin"], typing.Any] diff --git a/src/roamhq/group/types/create_group_request_members_item.py b/src/roamhq/group/types/create_group_request_members_item.py new file mode 100644 index 0000000..634ccc2 --- /dev/null +++ b/src/roamhq/group/types/create_group_request_members_item.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .create_group_request_members_item_role import CreateGroupRequestMembersItemRole + + +class CreateGroupRequestMembersItem(UniversalBaseModel): + user_id: typing_extensions.Annotated[ + str, FieldMetadata(alias="userId"), pydantic.Field(alias="userId", description="User ID or email address") + ] + """ + User ID or email address + """ + + role: CreateGroupRequestMembersItemRole = pydantic.Field() + """ + Role for this member + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/group/types/create_group_request_members_item_role.py b/src/roamhq/group/types/create_group_request_members_item_role.py new file mode 100644 index 0000000..e6c5fbc --- /dev/null +++ b/src/roamhq/group/types/create_group_request_members_item_role.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +CreateGroupRequestMembersItemRole = typing.Union[typing.Literal["member", "admin"], typing.Any] diff --git a/src/roamhq/group/types/list_group_response.py b/src/roamhq/group/types/list_group_response.py new file mode 100644 index 0000000..7701bcb --- /dev/null +++ b/src/roamhq/group/types/list_group_response.py @@ -0,0 +1,35 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_group_response_groups_item import ListGroupResponseGroupsItem + + +class ListGroupResponse(UniversalBaseModel): + groups: typing.List[ListGroupResponseGroupsItem] + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field( + alias="nextCursor", + description="Pagination cursor for the next page. Absent when there are no more results.", + ), + ] = None + """ + Pagination cursor for the next page. Absent when there are no more results. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/group/types/list_group_response_groups_item.py b/src/roamhq/group/types/list_group_response_groups_item.py new file mode 100644 index 0000000..87ee2c8 --- /dev/null +++ b/src/roamhq/group/types/list_group_response_groups_item.py @@ -0,0 +1,67 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_group_response_groups_item_access_mode import ListGroupResponseGroupsItemAccessMode +from .list_group_response_groups_item_type import ListGroupResponseGroupsItemType + + +class ListGroupResponseGroupsItem(UniversalBaseModel): + id: str = pydantic.Field() + """ + The group ID. + """ + + name: str + description: typing.Optional[str] = pydantic.Field(default=None) + """ + Group description, if set. + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="Group image URL, if set."), + ] = None + """ + Group image URL, if set. + """ + + type: ListGroupResponseGroupsItemType = pydantic.Field() + """ + Group type. + """ + + access_mode: typing_extensions.Annotated[ + typing.Optional[ListGroupResponseGroupsItemAccessMode], + FieldMetadata(alias="accessMode"), + pydantic.Field(alias="accessMode", description="Whether the group is public or private."), + ] = None + """ + Whether the group is public or private. + """ + + date_created: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="dateCreated"), + pydantic.Field(alias="dateCreated", description="When the group was created (RFC3339, caller's timezone)."), + ] = None + """ + When the group was created (RFC3339, caller's timezone). + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/group/types/list_group_response_groups_item_access_mode.py b/src/roamhq/group/types/list_group_response_groups_item_access_mode.py new file mode 100644 index 0000000..6f2cd28 --- /dev/null +++ b/src/roamhq/group/types/list_group_response_groups_item_access_mode.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ListGroupResponseGroupsItemAccessMode = typing.Union[typing.Literal["public", "private"], typing.Any] diff --git a/src/roamhq/group/types/list_group_response_groups_item_type.py b/src/roamhq/group/types/list_group_response_groups_item_type.py new file mode 100644 index 0000000..8b6f558 --- /dev/null +++ b/src/roamhq/group/types/list_group_response_groups_item_type.py @@ -0,0 +1,9 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ListGroupResponseGroupsItemType = typing.Union[ + typing.Literal["standard", "magicast", "meeting", "roam", "onair", "community"], typing.Any +] diff --git a/src/roamhq/group/types/members_group_response.py b/src/roamhq/group/types/members_group_response.py new file mode 100644 index 0000000..89b2183 --- /dev/null +++ b/src/roamhq/group/types/members_group_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.group_member import GroupMember + + +class MembersGroupResponse(UniversalBaseModel): + members: typing.Optional[typing.List[GroupMember]] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Pagination cursor for fetching the next page of results"), + ] = None + """ + Pagination cursor for fetching the next page of results + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/groups/__init__.py b/src/roamhq/groups/__init__.py new file mode 100644 index 0000000..d222846 --- /dev/null +++ b/src/roamhq/groups/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import GroupsListResponseItem +_dynamic_imports: typing.Dict[str, str] = {"GroupsListResponseItem": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["GroupsListResponseItem"] diff --git a/src/roamhq/groups/client.py b/src/roamhq/groups/client.py new file mode 100644 index 0000000..5092e5a --- /dev/null +++ b/src/roamhq/groups/client.py @@ -0,0 +1,126 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawGroupsClient, RawGroupsClient +from .types.groups_list_response_item import GroupsListResponseItem + + +class GroupsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawGroupsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawGroupsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawGroupsClient + """ + return self._raw_client + + def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> typing.List[GroupsListResponseItem]: + """ + **Legacy:** Prefer [`/group.list`](https://developer.ro.am/docs/api/group-list) for new integrations. + + Lists all public, non-archived groups in your home Roam. + + Unlike `/group.list`, this endpoint returns a **raw JSON array** (not the + `{"ok": true, …}` envelope). It is the sole ok-envelope exception on `/v1` + and remains only for existing callers. + + **Access:** Organization only. + + **Required scope:** `group:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + typing.List[GroupsListResponseItem] + OK. **Note:** response is a raw array, not the v1 `ok` envelope. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.groups.list() + """ + _response = self._raw_client.list(request_options=request_options) + return _response.data + + +class AsyncGroupsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawGroupsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawGroupsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawGroupsClient + """ + return self._raw_client + + async def list( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> typing.List[GroupsListResponseItem]: + """ + **Legacy:** Prefer [`/group.list`](https://developer.ro.am/docs/api/group-list) for new integrations. + + Lists all public, non-archived groups in your home Roam. + + Unlike `/group.list`, this endpoint returns a **raw JSON array** (not the + `{"ok": true, …}` envelope). It is the sole ok-envelope exception on `/v1` + and remains only for existing callers. + + **Access:** Organization only. + + **Required scope:** `group:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + typing.List[GroupsListResponseItem] + OK. **Note:** response is a raw array, not the v1 `ok` envelope. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.groups.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list(request_options=request_options) + return _response.data diff --git a/src/roamhq/groups/raw_client.py b/src/roamhq/groups/raw_client.py new file mode 100644 index 0000000..9fb12ea --- /dev/null +++ b/src/roamhq/groups/raw_client.py @@ -0,0 +1,195 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.internal_server_error import InternalServerError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.groups_list_response_item import GroupsListResponseItem +from pydantic import ValidationError + + +class RawGroupsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[typing.List[GroupsListResponseItem]]: + """ + **Legacy:** Prefer [`/group.list`](https://developer.ro.am/docs/api/group-list) for new integrations. + + Lists all public, non-archived groups in your home Roam. + + Unlike `/group.list`, this endpoint returns a **raw JSON array** (not the + `{"ok": true, …}` envelope). It is the sole ok-envelope exception on `/v1` + and remains only for existing callers. + + **Access:** Organization only. + + **Required scope:** `group:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[typing.List[GroupsListResponseItem]] + OK. **Note:** response is a raw array, not the v1 `ok` envelope. + """ + _response = self._client_wrapper.httpx_client.request( + "groups.list", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + typing.List[GroupsListResponseItem], + parse_obj_as( + type_=typing.List[GroupsListResponseItem], # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawGroupsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[typing.List[GroupsListResponseItem]]: + """ + **Legacy:** Prefer [`/group.list`](https://developer.ro.am/docs/api/group-list) for new integrations. + + Lists all public, non-archived groups in your home Roam. + + Unlike `/group.list`, this endpoint returns a **raw JSON array** (not the + `{"ok": true, …}` envelope). It is the sole ok-envelope exception on `/v1` + and remains only for existing callers. + + **Access:** Organization only. + + **Required scope:** `group:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[typing.List[GroupsListResponseItem]] + OK. **Note:** response is a raw array, not the v1 `ok` envelope. + """ + _response = await self._client_wrapper.httpx_client.request( + "groups.list", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + typing.List[GroupsListResponseItem], + parse_obj_as( + type_=typing.List[GroupsListResponseItem], # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/groups/types/__init__.py b/src/roamhq/groups/types/__init__.py new file mode 100644 index 0000000..4a5b86b --- /dev/null +++ b/src/roamhq/groups/types/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .groups_list_response_item import GroupsListResponseItem +_dynamic_imports: typing.Dict[str, str] = {"GroupsListResponseItem": ".groups_list_response_item"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["GroupsListResponseItem"] diff --git a/src/roamhq/groups/types/groups_list_response_item.py b/src/roamhq/groups/types/groups_list_response_item.py new file mode 100644 index 0000000..c1eac65 --- /dev/null +++ b/src/roamhq/groups/types/groups_list_response_item.py @@ -0,0 +1,54 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class GroupsListResponseItem(UniversalBaseModel): + """ + Legacy group row from `groups.list` (not the v1 Group object). + """ + + address_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="addressId"), pydantic.Field(alias="addressId") + ] = None + roam_id: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="roamId"), pydantic.Field(alias="roamId") + ] = None + account_id: typing_extensions.Annotated[ + typing.Optional[int], FieldMetadata(alias="accountId"), pydantic.Field(alias="accountId") + ] = None + group_type: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="groupType"), pydantic.Field(alias="groupType") + ] = None + name: typing.Optional[str] = None + access_mode: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="accessMode"), pydantic.Field(alias="accessMode") + ] = None + group_management: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="groupManagement"), pydantic.Field(alias="groupManagement") + ] = None + enforce_threaded_mode: typing_extensions.Annotated[ + typing.Optional[bool], FieldMetadata(alias="enforceThreadedMode"), pydantic.Field(alias="enforceThreadedMode") + ] = None + date_created: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="dateCreated"), pydantic.Field(alias="dateCreated") + ] = None + image_url: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="imageUrl"), pydantic.Field(alias="imageUrl") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/item/__init__.py b/src/roamhq/item/__init__.py new file mode 100644 index 0000000..2038c2a --- /dev/null +++ b/src/roamhq/item/__init__.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + diff --git a/src/roamhq/item/client.py b/src/roamhq/item/client.py new file mode 100644 index 0000000..ce1e964 --- /dev/null +++ b/src/roamhq/item/client.py @@ -0,0 +1,139 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.chat_item import ChatItem +from .raw_client import AsyncRawItemClient, RawItemClient + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class ItemClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawItemClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawItemClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawItemClient + """ + return self._raw_client + + def upload( + self, + *, + request: typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]], + request_options: typing.Optional[RequestOptions] = None, + ) -> ChatItem: + """ + Upload a file so that it can be sent as a chat message attachment. + The returned object contains an item ID which can be used with [chat.post](https://developer.ro.am/docs/api/chat-post). + + Unlike other endpoints, this uses raw binary upload with metadata in HTTP headers + rather than JSON. This is more efficient for file transfers. + + **Limits:** + - Maximum file size: 10 MB + + **Supported Content Types:** + + | Content-Type | In-Product Behavior | + |--------------|---------------------| + | `image/png`, `image/jpeg`, `image/gif`, `image/webp` | Displayed inline with preview thumbnail | + | `application/octet-stream` | Download link only (no preview) | + + **Important:** Use `application/octet-stream` for **any file type not listed above** (e.g., `.txt`, `.docx`, `.xlsx`, `.zip`, `.pdf`, etc.). + These files will be stored and downloadable, but won't have in-product preview functionality. + + **Validation:** + - The `Content-Type` header must match the actual file content (server validates this for images) + - For images, if the filename lacks the correct extension, it will be appended automatically + + **Required scope:** `item:write` + + Parameters + ---------- + request : typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ChatItem + Item uploaded successfully + """ + _response = self._raw_client.upload(request=request, request_options=request_options) + return _response.data + + +class AsyncItemClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawItemClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawItemClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawItemClient + """ + return self._raw_client + + async def upload( + self, + *, + request: typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]], + request_options: typing.Optional[RequestOptions] = None, + ) -> ChatItem: + """ + Upload a file so that it can be sent as a chat message attachment. + The returned object contains an item ID which can be used with [chat.post](https://developer.ro.am/docs/api/chat-post). + + Unlike other endpoints, this uses raw binary upload with metadata in HTTP headers + rather than JSON. This is more efficient for file transfers. + + **Limits:** + - Maximum file size: 10 MB + + **Supported Content Types:** + + | Content-Type | In-Product Behavior | + |--------------|---------------------| + | `image/png`, `image/jpeg`, `image/gif`, `image/webp` | Displayed inline with preview thumbnail | + | `application/octet-stream` | Download link only (no preview) | + + **Important:** Use `application/octet-stream` for **any file type not listed above** (e.g., `.txt`, `.docx`, `.xlsx`, `.zip`, `.pdf`, etc.). + These files will be stored and downloadable, but won't have in-product preview functionality. + + **Validation:** + - The `Content-Type` header must match the actual file content (server validates this for images) + - For images, if the filename lacks the correct extension, it will be appended automatically + + **Required scope:** `item:write` + + Parameters + ---------- + request : typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ChatItem + Item uploaded successfully + """ + _response = await self._raw_client.upload(request=request, request_options=request_options) + return _response.data diff --git a/src/roamhq/item/raw_client.py b/src/roamhq/item/raw_client.py new file mode 100644 index 0000000..50b68ad --- /dev/null +++ b/src/roamhq/item/raw_client.py @@ -0,0 +1,313 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..errors.unsupported_media_type_error import UnsupportedMediaTypeError +from ..types.chat_item import ChatItem +from ..types.error import Error +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawItemClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def upload( + self, + *, + request: typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]], + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ChatItem]: + """ + Upload a file so that it can be sent as a chat message attachment. + The returned object contains an item ID which can be used with [chat.post](https://developer.ro.am/docs/api/chat-post). + + Unlike other endpoints, this uses raw binary upload with metadata in HTTP headers + rather than JSON. This is more efficient for file transfers. + + **Limits:** + - Maximum file size: 10 MB + + **Supported Content Types:** + + | Content-Type | In-Product Behavior | + |--------------|---------------------| + | `image/png`, `image/jpeg`, `image/gif`, `image/webp` | Displayed inline with preview thumbnail | + | `application/octet-stream` | Download link only (no preview) | + + **Important:** Use `application/octet-stream` for **any file type not listed above** (e.g., `.txt`, `.docx`, `.xlsx`, `.zip`, `.pdf`, etc.). + These files will be stored and downloadable, but won't have in-product preview functionality. + + **Validation:** + - The `Content-Type` header must match the actual file content (server validates this for images) + - For images, if the filename lacks the correct extension, it will be appended automatically + + **Required scope:** `item:write` + + Parameters + ---------- + request : typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ChatItem] + Item uploaded successfully + """ + _response = self._client_wrapper.httpx_client.request( + "item.upload", + method="POST", + content=request, + headers={ + "content-type": "image/png", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ChatItem, + parse_obj_as( + type_=ChatItem, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 415: + raise UnsupportedMediaTypeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawItemClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def upload( + self, + *, + request: typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]], + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ChatItem]: + """ + Upload a file so that it can be sent as a chat message attachment. + The returned object contains an item ID which can be used with [chat.post](https://developer.ro.am/docs/api/chat-post). + + Unlike other endpoints, this uses raw binary upload with metadata in HTTP headers + rather than JSON. This is more efficient for file transfers. + + **Limits:** + - Maximum file size: 10 MB + + **Supported Content Types:** + + | Content-Type | In-Product Behavior | + |--------------|---------------------| + | `image/png`, `image/jpeg`, `image/gif`, `image/webp` | Displayed inline with preview thumbnail | + | `application/octet-stream` | Download link only (no preview) | + + **Important:** Use `application/octet-stream` for **any file type not listed above** (e.g., `.txt`, `.docx`, `.xlsx`, `.zip`, `.pdf`, etc.). + These files will be stored and downloadable, but won't have in-product preview functionality. + + **Validation:** + - The `Content-Type` header must match the actual file content (server validates this for images) + - For images, if the filename lacks the correct extension, it will be appended automatically + + **Required scope:** `item:write` + + Parameters + ---------- + request : typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ChatItem] + Item uploaded successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "item.upload", + method="POST", + content=request, + headers={ + "content-type": "image/png", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ChatItem, + parse_obj_as( + type_=ChatItem, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 415: + raise UnsupportedMediaTypeError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/lobby/__init__.py b/src/roamhq/lobby/__init__.py new file mode 100644 index 0000000..1bc61ef --- /dev/null +++ b/src/roamhq/lobby/__init__.py @@ -0,0 +1,40 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ListBookingsLobbyResponse, ListLobbyResponse, ListLobbyResponseLobbiesItem +_dynamic_imports: typing.Dict[str, str] = { + "ListBookingsLobbyResponse": ".types", + "ListLobbyResponse": ".types", + "ListLobbyResponseLobbiesItem": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListBookingsLobbyResponse", "ListLobbyResponse", "ListLobbyResponseLobbiesItem"] diff --git a/src/roamhq/lobby/client.py b/src/roamhq/lobby/client.py new file mode 100644 index 0000000..28e8d43 --- /dev/null +++ b/src/roamhq/lobby/client.py @@ -0,0 +1,300 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawLobbyClient, RawLobbyClient +from .types.list_bookings_lobby_response import ListBookingsLobbyResponse +from .types.list_lobby_response import ListLobbyResponse + + +class LobbyClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawLobbyClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawLobbyClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawLobbyClient + """ + return self._raw_client + + def list( + self, *, handle: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> ListLobbyResponse: + """ + Lists active lobbies in your account. + + A lobby URL has the form `ro.am/{handle}` or `ro.am/{handle}/{slug}`. + - The "handle" is the first path segment + - The "slug" is the optional second path segment. It may be empty for the default lobby under a handle + + Optionally filter by a specific lobby handle. If provided, only lobbies + associated with that handle are returned. + + This endpoint is **not paginated**. The 200 body is `{ "lobbies": [...] }` + with every matching lobby; there is no `cursor` / `nextCursor` and no + `data` array. The TypeScript SDK returns that object directly, not a + page helper. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + handle : typing.Optional[str] + Filter by lobby handle (first path segment), e.g., `robfig` for + `ro.am/robfig` or `ro.am/robfig/tour`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListLobbyResponse + OK + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.lobby.list() + """ + _response = self._raw_client.list(handle=handle, request_options=request_options) + return _response.data + + def list_bookings( + self, + *, + lobby_id: str, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListBookingsLobbyResponse: + """ + Lists bookings for a specific lobby configuration, filtered by date range (after/before). + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent bookings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + lobby_id : str + The lobby configuration ID to list bookings for. + + after : typing.Optional[dt.datetime] + The datetime to begin listing bookings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[dt.datetime] + The datetime until which to list bookings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of bookings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListBookingsLobbyResponse + OK + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.lobby.list_bookings( + lobby_id="lobbyId", + ) + """ + _response = self._raw_client.list_bookings( + lobby_id=lobby_id, after=after, before=before, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + +class AsyncLobbyClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawLobbyClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawLobbyClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawLobbyClient + """ + return self._raw_client + + async def list( + self, *, handle: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> ListLobbyResponse: + """ + Lists active lobbies in your account. + + A lobby URL has the form `ro.am/{handle}` or `ro.am/{handle}/{slug}`. + - The "handle" is the first path segment + - The "slug" is the optional second path segment. It may be empty for the default lobby under a handle + + Optionally filter by a specific lobby handle. If provided, only lobbies + associated with that handle are returned. + + This endpoint is **not paginated**. The 200 body is `{ "lobbies": [...] }` + with every matching lobby; there is no `cursor` / `nextCursor` and no + `data` array. The TypeScript SDK returns that object directly, not a + page helper. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + handle : typing.Optional[str] + Filter by lobby handle (first path segment), e.g., `robfig` for + `ro.am/robfig` or `ro.am/robfig/tour`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListLobbyResponse + OK + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.lobby.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list(handle=handle, request_options=request_options) + return _response.data + + async def list_bookings( + self, + *, + lobby_id: str, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListBookingsLobbyResponse: + """ + Lists bookings for a specific lobby configuration, filtered by date range (after/before). + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent bookings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + lobby_id : str + The lobby configuration ID to list bookings for. + + after : typing.Optional[dt.datetime] + The datetime to begin listing bookings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[dt.datetime] + The datetime until which to list bookings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of bookings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListBookingsLobbyResponse + OK + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.lobby.list_bookings( + lobby_id="lobbyId", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_bookings( + lobby_id=lobby_id, after=after, before=before, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data diff --git a/src/roamhq/lobby/raw_client.py b/src/roamhq/lobby/raw_client.py new file mode 100644 index 0000000..ac89630 --- /dev/null +++ b/src/roamhq/lobby/raw_client.py @@ -0,0 +1,595 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.datetime_utils import serialize_datetime +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.list_bookings_lobby_response import ListBookingsLobbyResponse +from .types.list_lobby_response import ListLobbyResponse +from pydantic import ValidationError + + +class RawLobbyClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, *, handle: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ListLobbyResponse]: + """ + Lists active lobbies in your account. + + A lobby URL has the form `ro.am/{handle}` or `ro.am/{handle}/{slug}`. + - The "handle" is the first path segment + - The "slug" is the optional second path segment. It may be empty for the default lobby under a handle + + Optionally filter by a specific lobby handle. If provided, only lobbies + associated with that handle are returned. + + This endpoint is **not paginated**. The 200 body is `{ "lobbies": [...] }` + with every matching lobby; there is no `cursor` / `nextCursor` and no + `data` array. The TypeScript SDK returns that object directly, not a + page helper. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + handle : typing.Optional[str] + Filter by lobby handle (first path segment), e.g., `robfig` for + `ro.am/robfig` or `ro.am/robfig/tour`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListLobbyResponse] + OK + """ + _response = self._client_wrapper.httpx_client.request( + "lobby.list", + method="GET", + params={ + "handle": handle, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListLobbyResponse, + parse_obj_as( + type_=ListLobbyResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_bookings( + self, + *, + lobby_id: str, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListBookingsLobbyResponse]: + """ + Lists bookings for a specific lobby configuration, filtered by date range (after/before). + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent bookings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + lobby_id : str + The lobby configuration ID to list bookings for. + + after : typing.Optional[dt.datetime] + The datetime to begin listing bookings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[dt.datetime] + The datetime until which to list bookings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of bookings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListBookingsLobbyResponse] + OK + """ + _response = self._client_wrapper.httpx_client.request( + "lobby.booking.list", + method="GET", + params={ + "lobbyId": lobby_id, + "after": serialize_datetime(after) if after is not None else None, + "before": serialize_datetime(before) if before is not None else None, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListBookingsLobbyResponse, + parse_obj_as( + type_=ListBookingsLobbyResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawLobbyClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, *, handle: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ListLobbyResponse]: + """ + Lists active lobbies in your account. + + A lobby URL has the form `ro.am/{handle}` or `ro.am/{handle}/{slug}`. + - The "handle" is the first path segment + - The "slug" is the optional second path segment. It may be empty for the default lobby under a handle + + Optionally filter by a specific lobby handle. If provided, only lobbies + associated with that handle are returned. + + This endpoint is **not paginated**. The 200 body is `{ "lobbies": [...] }` + with every matching lobby; there is no `cursor` / `nextCursor` and no + `data` array. The TypeScript SDK returns that object directly, not a + page helper. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + handle : typing.Optional[str] + Filter by lobby handle (first path segment), e.g., `robfig` for + `ro.am/robfig` or `ro.am/robfig/tour`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListLobbyResponse] + OK + """ + _response = await self._client_wrapper.httpx_client.request( + "lobby.list", + method="GET", + params={ + "handle": handle, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListLobbyResponse, + parse_obj_as( + type_=ListLobbyResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_bookings( + self, + *, + lobby_id: str, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListBookingsLobbyResponse]: + """ + Lists bookings for a specific lobby configuration, filtered by date range (after/before). + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent bookings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization and Personal. + + **Required scope:** `lobby:read` + + Parameters + ---------- + lobby_id : str + The lobby configuration ID to list bookings for. + + after : typing.Optional[dt.datetime] + The datetime to begin listing bookings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[dt.datetime] + The datetime until which to list bookings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of bookings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListBookingsLobbyResponse] + OK + """ + _response = await self._client_wrapper.httpx_client.request( + "lobby.booking.list", + method="GET", + params={ + "lobbyId": lobby_id, + "after": serialize_datetime(after) if after is not None else None, + "before": serialize_datetime(before) if before is not None else None, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListBookingsLobbyResponse, + parse_obj_as( + type_=ListBookingsLobbyResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/lobby/types/__init__.py b/src/roamhq/lobby/types/__init__.py new file mode 100644 index 0000000..cfce9d6 --- /dev/null +++ b/src/roamhq/lobby/types/__init__.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .list_bookings_lobby_response import ListBookingsLobbyResponse + from .list_lobby_response import ListLobbyResponse + from .list_lobby_response_lobbies_item import ListLobbyResponseLobbiesItem +_dynamic_imports: typing.Dict[str, str] = { + "ListBookingsLobbyResponse": ".list_bookings_lobby_response", + "ListLobbyResponse": ".list_lobby_response", + "ListLobbyResponseLobbiesItem": ".list_lobby_response_lobbies_item", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListBookingsLobbyResponse", "ListLobbyResponse", "ListLobbyResponseLobbiesItem"] diff --git a/src/roamhq/lobby/types/list_bookings_lobby_response.py b/src/roamhq/lobby/types/list_bookings_lobby_response.py new file mode 100644 index 0000000..5655dc9 --- /dev/null +++ b/src/roamhq/lobby/types/list_bookings_lobby_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.lobby_booking import LobbyBooking + + +class ListBookingsLobbyResponse(UniversalBaseModel): + bookings: typing.Optional[typing.List[LobbyBooking]] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Returned if there is a subsequent page of bookings."), + ] = None + """ + Returned if there is a subsequent page of bookings. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/lobby/types/list_lobby_response.py b/src/roamhq/lobby/types/list_lobby_response.py new file mode 100644 index 0000000..c6fe9ca --- /dev/null +++ b/src/roamhq/lobby/types/list_lobby_response.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .list_lobby_response_lobbies_item import ListLobbyResponseLobbiesItem + + +class ListLobbyResponse(UniversalBaseModel): + lobbies: typing.Optional[typing.List[ListLobbyResponseLobbiesItem]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/lobby/types/list_lobby_response_lobbies_item.py b/src/roamhq/lobby/types/list_lobby_response_lobbies_item.py new file mode 100644 index 0000000..f64ac3a --- /dev/null +++ b/src/roamhq/lobby/types/list_lobby_response_lobbies_item.py @@ -0,0 +1,55 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class ListLobbyResponseLobbiesItem(UniversalBaseModel): + id: typing.Optional[str] = pydantic.Field(default=None) + """ + Unique identifier of the lobby configuration (UUID) + """ + + slug: typing.Optional[str] = pydantic.Field(default=None) + """ + Optional second path segment for the lobby. May be empty. + """ + + display_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="displayName"), + pydantic.Field(alias="displayName", description="Human-readable name of the lobby configuration"), + ] = None + """ + Human-readable name of the lobby configuration + """ + + active: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether the lobby configuration is active + """ + + url: typing.Optional[str] = pydantic.Field(default=None) + """ + Public URL of the lobby (e.g., `https://ro.am/handle` or `https://ro.am/handle/slug`) + """ + + handle: typing.Optional[str] = pydantic.Field(default=None) + """ + First path segment of the lobby URL + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/magicast/__init__.py b/src/roamhq/magicast/__init__.py new file mode 100644 index 0000000..7cdac95 --- /dev/null +++ b/src/roamhq/magicast/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ListMagicastResponse +_dynamic_imports: typing.Dict[str, str] = {"ListMagicastResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListMagicastResponse"] diff --git a/src/roamhq/magicast/client.py b/src/roamhq/magicast/client.py new file mode 100644 index 0000000..d03e3c7 --- /dev/null +++ b/src/roamhq/magicast/client.py @@ -0,0 +1,294 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.magicast_info import MagicastInfo +from .raw_client import AsyncRawMagicastClient, RawMagicastClient +from .types.list_magicast_response import ListMagicastResponse + + +class MagicastClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawMagicastClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawMagicastClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawMagicastClient + """ + return self._raw_client + + def list( + self, + *, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListMagicastResponse: + """ + List Magicasts in your account, most recent first. + + Returns metadata only (`id`, `name`, `createdAt`, `ownerId`, + `coverImageUrl`). Use [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info) for + transcript cues, chapters, video status, and a signed download URL. + + **Access:** Organization and Personal. Organization tokens list every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + after : typing.Optional[dt.datetime] + Only return magicasts created after this time (RFC-3339). + + before : typing.Optional[dt.datetime] + Only return magicasts created before this time (RFC-3339). + + ascending : typing.Optional[bool] + Sort oldest-first instead of newest-first. + + limit : typing.Optional[int] + Number of magicasts to return per response. Default 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListMagicastResponse + Magicasts retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.magicast.list() + """ + _response = self._raw_client.list( + after=after, before=before, ascending=ascending, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + def info(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> MagicastInfo: + """ + Get details for a single Magicast by ID, including transcript cues, + chapters, video status, a signed video download URL when ready, and a + player URL if a share link already exists. + + This is the content endpoint. [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) + returns metadata only. Magicasts are not meetings — they do not appear on + [`/recording.list`](https://developer.ro.am/docs/api/recording-list) or meeting transcript + surfaces, and they have no Magic Minutes summary or action items. + + Asset, transcript, and share-link lookups are best-effort. If the video or + transcript is still processing, those fields are omitted and the request + still succeeds. Fetching this endpoint **never** mints a shareable link; + use [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link) for that. + + There is no `https://ro.am/magicast/{id}` browser URL. The player URL is + always `https://ro.am/share/{key}`. + + **Access:** Organization and Personal. Organization tokens can read every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. Filter + on whether `shareUrl` is present if you only want shared recordings. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + MagicastInfo + Magicast retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.magicast.info( + id="id", + ) + """ + _response = self._raw_client.info(id=id, request_options=request_options) + return _response.data + + +class AsyncMagicastClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawMagicastClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawMagicastClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawMagicastClient + """ + return self._raw_client + + async def list( + self, + *, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListMagicastResponse: + """ + List Magicasts in your account, most recent first. + + Returns metadata only (`id`, `name`, `createdAt`, `ownerId`, + `coverImageUrl`). Use [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info) for + transcript cues, chapters, video status, and a signed download URL. + + **Access:** Organization and Personal. Organization tokens list every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + after : typing.Optional[dt.datetime] + Only return magicasts created after this time (RFC-3339). + + before : typing.Optional[dt.datetime] + Only return magicasts created before this time (RFC-3339). + + ascending : typing.Optional[bool] + Sort oldest-first instead of newest-first. + + limit : typing.Optional[int] + Number of magicasts to return per response. Default 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListMagicastResponse + Magicasts retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.magicast.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + after=after, before=before, ascending=ascending, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + async def info(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> MagicastInfo: + """ + Get details for a single Magicast by ID, including transcript cues, + chapters, video status, a signed video download URL when ready, and a + player URL if a share link already exists. + + This is the content endpoint. [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) + returns metadata only. Magicasts are not meetings — they do not appear on + [`/recording.list`](https://developer.ro.am/docs/api/recording-list) or meeting transcript + surfaces, and they have no Magic Minutes summary or action items. + + Asset, transcript, and share-link lookups are best-effort. If the video or + transcript is still processing, those fields are omitted and the request + still succeeds. Fetching this endpoint **never** mints a shareable link; + use [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link) for that. + + There is no `https://ro.am/magicast/{id}` browser URL. The player URL is + always `https://ro.am/share/{key}`. + + **Access:** Organization and Personal. Organization tokens can read every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. Filter + on whether `shareUrl` is present if you only want shared recordings. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + MagicastInfo + Magicast retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.magicast.info( + id="id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.info(id=id, request_options=request_options) + return _response.data diff --git a/src/roamhq/magicast/raw_client.py b/src/roamhq/magicast/raw_client.py new file mode 100644 index 0000000..b918279 --- /dev/null +++ b/src/roamhq/magicast/raw_client.py @@ -0,0 +1,569 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.datetime_utils import serialize_datetime +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from ..types.magicast_info import MagicastInfo +from .types.list_magicast_response import ListMagicastResponse +from pydantic import ValidationError + + +class RawMagicastClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, + *, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListMagicastResponse]: + """ + List Magicasts in your account, most recent first. + + Returns metadata only (`id`, `name`, `createdAt`, `ownerId`, + `coverImageUrl`). Use [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info) for + transcript cues, chapters, video status, and a signed download URL. + + **Access:** Organization and Personal. Organization tokens list every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + after : typing.Optional[dt.datetime] + Only return magicasts created after this time (RFC-3339). + + before : typing.Optional[dt.datetime] + Only return magicasts created before this time (RFC-3339). + + ascending : typing.Optional[bool] + Sort oldest-first instead of newest-first. + + limit : typing.Optional[int] + Number of magicasts to return per response. Default 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListMagicastResponse] + Magicasts retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "magicast.list", + method="GET", + params={ + "after": serialize_datetime(after) if after is not None else None, + "before": serialize_datetime(before) if before is not None else None, + "ascending": ascending, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListMagicastResponse, + parse_obj_as( + type_=ListMagicastResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def info(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[MagicastInfo]: + """ + Get details for a single Magicast by ID, including transcript cues, + chapters, video status, a signed video download URL when ready, and a + player URL if a share link already exists. + + This is the content endpoint. [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) + returns metadata only. Magicasts are not meetings — they do not appear on + [`/recording.list`](https://developer.ro.am/docs/api/recording-list) or meeting transcript + surfaces, and they have no Magic Minutes summary or action items. + + Asset, transcript, and share-link lookups are best-effort. If the video or + transcript is still processing, those fields are omitted and the request + still succeeds. Fetching this endpoint **never** mints a shareable link; + use [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link) for that. + + There is no `https://ro.am/magicast/{id}` browser URL. The player URL is + always `https://ro.am/share/{key}`. + + **Access:** Organization and Personal. Organization tokens can read every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. Filter + on whether `shareUrl` is present if you only want shared recordings. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[MagicastInfo] + Magicast retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "magicast.info", + method="GET", + params={ + "id": id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + MagicastInfo, + parse_obj_as( + type_=MagicastInfo, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawMagicastClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, + *, + after: typing.Optional[dt.datetime] = None, + before: typing.Optional[dt.datetime] = None, + ascending: typing.Optional[bool] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListMagicastResponse]: + """ + List Magicasts in your account, most recent first. + + Returns metadata only (`id`, `name`, `createdAt`, `ownerId`, + `coverImageUrl`). Use [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info) for + transcript cues, chapters, video status, and a signed download URL. + + **Access:** Organization and Personal. Organization tokens list every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + after : typing.Optional[dt.datetime] + Only return magicasts created after this time (RFC-3339). + + before : typing.Optional[dt.datetime] + Only return magicasts created before this time (RFC-3339). + + ascending : typing.Optional[bool] + Sort oldest-first instead of newest-first. + + limit : typing.Optional[int] + Number of magicasts to return per response. Default 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListMagicastResponse] + Magicasts retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "magicast.list", + method="GET", + params={ + "after": serialize_datetime(after) if after is not None else None, + "before": serialize_datetime(before) if before is not None else None, + "ascending": ascending, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListMagicastResponse, + parse_obj_as( + type_=ListMagicastResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def info( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[MagicastInfo]: + """ + Get details for a single Magicast by ID, including transcript cues, + chapters, video status, a signed video download URL when ready, and a + player URL if a share link already exists. + + This is the content endpoint. [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) + returns metadata only. Magicasts are not meetings — they do not appear on + [`/recording.list`](https://developer.ro.am/docs/api/recording-list) or meeting transcript + surfaces, and they have no Magic Minutes summary or action items. + + Asset, transcript, and share-link lookups are best-effort. If the video or + transcript is still processing, those fields are omitted and the request + still succeeds. Fetching this endpoint **never** mints a shareable link; + use [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link) for that. + + There is no `https://ro.am/magicast/{id}` browser URL. The player URL is + always `https://ro.am/share/{key}`. + + **Access:** Organization and Personal. Organization tokens can read every + Magicast in the account, including ones the creator never shared. Personal + tokens are restricted to Magicasts owned by the authenticated user. Filter + on whether `shareUrl` is present if you only want shared recordings. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[MagicastInfo] + Magicast retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "magicast.info", + method="GET", + params={ + "id": id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + MagicastInfo, + parse_obj_as( + type_=MagicastInfo, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/magicast/types/__init__.py b/src/roamhq/magicast/types/__init__.py new file mode 100644 index 0000000..5770081 --- /dev/null +++ b/src/roamhq/magicast/types/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .list_magicast_response import ListMagicastResponse +_dynamic_imports: typing.Dict[str, str] = {"ListMagicastResponse": ".list_magicast_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListMagicastResponse"] diff --git a/src/roamhq/magicast/types/list_magicast_response.py b/src/roamhq/magicast/types/list_magicast_response.py new file mode 100644 index 0000000..a9953d8 --- /dev/null +++ b/src/roamhq/magicast/types/list_magicast_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.magicast import Magicast + + +class ListMagicastResponse(UniversalBaseModel): + magicasts: typing.Optional[typing.List[Magicast]] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Cursor for the next page; omitted on the last page."), + ] = None + """ + Cursor for the next page; omitted on the last page. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/magicasts/__init__.py b/src/roamhq/magicasts/__init__.py new file mode 100644 index 0000000..d80d04f --- /dev/null +++ b/src/roamhq/magicasts/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import MagicastShareLinkResponse +_dynamic_imports: typing.Dict[str, str] = {"MagicastShareLinkResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["MagicastShareLinkResponse"] diff --git a/src/roamhq/magicasts/client.py b/src/roamhq/magicasts/client.py new file mode 100644 index 0000000..2436183 --- /dev/null +++ b/src/roamhq/magicasts/client.py @@ -0,0 +1,167 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawMagicastsClient, RawMagicastsClient +from .types.magicast_share_link_response import MagicastShareLinkResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class MagicastsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawMagicastsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawMagicastsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawMagicastsClient + """ + return self._raw_client + + def magicast_share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> MagicastShareLinkResponse: + """ + Returns a shareable player URL for a Magicast. Pass the `id` obtained from + [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) or + [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info). + + This endpoint is **get-or-create**: it returns the Magicast's existing + share link, or mints one the first time it is called. Repeat calls for the + same Magicast return the same URL. + + Creating a share link is a deliberate action, which is why it has its own + endpoint rather than being returned as a field that is always present on + `magicast.list` / `magicast.info`. Fetching a Magicast never mints a + shareable link as a side effect. `magicast.info` includes `shareUrl` only + when a link already exists. + + The URL is `https://ro.am/share/{key}`. There is no + `https://ro.am/magicast/{id}` route. + + You can only create a share link for a Magicast you can access; the same + access check as `magicast.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to + Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The Magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + MagicastShareLinkResponse + The Magicast's shareable link. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.magicasts.magicast_share_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ) + """ + _response = self._raw_client.magicast_share_link(id=id, request_options=request_options) + return _response.data + + +class AsyncMagicastsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawMagicastsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawMagicastsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawMagicastsClient + """ + return self._raw_client + + async def magicast_share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> MagicastShareLinkResponse: + """ + Returns a shareable player URL for a Magicast. Pass the `id` obtained from + [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) or + [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info). + + This endpoint is **get-or-create**: it returns the Magicast's existing + share link, or mints one the first time it is called. Repeat calls for the + same Magicast return the same URL. + + Creating a share link is a deliberate action, which is why it has its own + endpoint rather than being returned as a field that is always present on + `magicast.list` / `magicast.info`. Fetching a Magicast never mints a + shareable link as a side effect. `magicast.info` includes `shareUrl` only + when a link already exists. + + The URL is `https://ro.am/share/{key}`. There is no + `https://ro.am/magicast/{id}` route. + + You can only create a share link for a Magicast you can access; the same + access check as `magicast.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to + Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The Magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + MagicastShareLinkResponse + The Magicast's shareable link. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.magicasts.magicast_share_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.magicast_share_link(id=id, request_options=request_options) + return _response.data diff --git a/src/roamhq/magicasts/raw_client.py b/src/roamhq/magicasts/raw_client.py new file mode 100644 index 0000000..796cf60 --- /dev/null +++ b/src/roamhq/magicasts/raw_client.py @@ -0,0 +1,313 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.magicast_share_link_response import MagicastShareLinkResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawMagicastsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def magicast_share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[MagicastShareLinkResponse]: + """ + Returns a shareable player URL for a Magicast. Pass the `id` obtained from + [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) or + [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info). + + This endpoint is **get-or-create**: it returns the Magicast's existing + share link, or mints one the first time it is called. Repeat calls for the + same Magicast return the same URL. + + Creating a share link is a deliberate action, which is why it has its own + endpoint rather than being returned as a field that is always present on + `magicast.list` / `magicast.info`. Fetching a Magicast never mints a + shareable link as a side effect. `magicast.info` includes `shareUrl` only + when a link already exists. + + The URL is `https://ro.am/share/{key}`. There is no + `https://ro.am/magicast/{id}` route. + + You can only create a share link for a Magicast you can access; the same + access check as `magicast.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to + Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The Magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[MagicastShareLinkResponse] + The Magicast's shareable link. + """ + _response = self._client_wrapper.httpx_client.request( + "magicast.shareLink", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + MagicastShareLinkResponse, + parse_obj_as( + type_=MagicastShareLinkResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawMagicastsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def magicast_share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[MagicastShareLinkResponse]: + """ + Returns a shareable player URL for a Magicast. Pass the `id` obtained from + [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) or + [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info). + + This endpoint is **get-or-create**: it returns the Magicast's existing + share link, or mints one the first time it is called. Repeat calls for the + same Magicast return the same URL. + + Creating a share link is a deliberate action, which is why it has its own + endpoint rather than being returned as a field that is always present on + `magicast.list` / `magicast.info`. Fetching a Magicast never mints a + shareable link as a side effect. `magicast.info` includes `shareUrl` only + when a link already exists. + + The URL is `https://ro.am/share/{key}`. There is no + `https://ro.am/magicast/{id}` route. + + You can only create a share link for a Magicast you can access; the same + access check as `magicast.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to + Magicasts owned by the authenticated user. + + **Required scope:** `magicast:read` + + Parameters + ---------- + id : str + The Magicast ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[MagicastShareLinkResponse] + The Magicast's shareable link. + """ + _response = await self._client_wrapper.httpx_client.request( + "magicast.shareLink", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + MagicastShareLinkResponse, + parse_obj_as( + type_=MagicastShareLinkResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/magicasts/types/__init__.py b/src/roamhq/magicasts/types/__init__.py new file mode 100644 index 0000000..82f3511 --- /dev/null +++ b/src/roamhq/magicasts/types/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .magicast_share_link_response import MagicastShareLinkResponse +_dynamic_imports: typing.Dict[str, str] = {"MagicastShareLinkResponse": ".magicast_share_link_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["MagicastShareLinkResponse"] diff --git a/src/roamhq/magicasts/types/magicast_share_link_response.py b/src/roamhq/magicasts/types/magicast_share_link_response.py new file mode 100644 index 0000000..4df418e --- /dev/null +++ b/src/roamhq/magicasts/types/magicast_share_link_response.py @@ -0,0 +1,29 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class MagicastShareLinkResponse(UniversalBaseModel): + id: str = pydantic.Field() + """ + The Magicast ID. + """ + + url: str = pydantic.Field() + """ + The shareable player URL (`https://ro.am/share/{key}`). + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/__init__.py b/src/roamhq/meeting/__init__.py new file mode 100644 index 0000000..fb511cd --- /dev/null +++ b/src/roamhq/meeting/__init__.py @@ -0,0 +1,90 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + CreateLinkMeetingResponse, + InfoMeetingResponse, + InfoMeetingResponseChaptersItem, + InfoMeetingResponseVideoStatus, + LinkInfoMeetingResponse, + ListMeetingResponse, + ListMeetingResponseMeetingsItem, + ListMeetingResponseMeetingsItemChaptersItem, + ListMeetingResponseMeetingsItemVideoStatus, + ParticipantsMeetingResponse, + PromptMeetingResponse, + SearchMeetingResponse, + SearchMeetingResponseInferredFilter, + SearchMeetingResponseResultsItem, + ShareLinkMeetingResponse, + TranscriptMeetingResponse, + TranscriptMeetingResponseCuesItem, + ) +_dynamic_imports: typing.Dict[str, str] = { + "CreateLinkMeetingResponse": ".types", + "InfoMeetingResponse": ".types", + "InfoMeetingResponseChaptersItem": ".types", + "InfoMeetingResponseVideoStatus": ".types", + "LinkInfoMeetingResponse": ".types", + "ListMeetingResponse": ".types", + "ListMeetingResponseMeetingsItem": ".types", + "ListMeetingResponseMeetingsItemChaptersItem": ".types", + "ListMeetingResponseMeetingsItemVideoStatus": ".types", + "ParticipantsMeetingResponse": ".types", + "PromptMeetingResponse": ".types", + "SearchMeetingResponse": ".types", + "SearchMeetingResponseInferredFilter": ".types", + "SearchMeetingResponseResultsItem": ".types", + "ShareLinkMeetingResponse": ".types", + "TranscriptMeetingResponse": ".types", + "TranscriptMeetingResponseCuesItem": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "CreateLinkMeetingResponse", + "InfoMeetingResponse", + "InfoMeetingResponseChaptersItem", + "InfoMeetingResponseVideoStatus", + "LinkInfoMeetingResponse", + "ListMeetingResponse", + "ListMeetingResponseMeetingsItem", + "ListMeetingResponseMeetingsItemChaptersItem", + "ListMeetingResponseMeetingsItemVideoStatus", + "ParticipantsMeetingResponse", + "PromptMeetingResponse", + "SearchMeetingResponse", + "SearchMeetingResponseInferredFilter", + "SearchMeetingResponseResultsItem", + "ShareLinkMeetingResponse", + "TranscriptMeetingResponse", + "TranscriptMeetingResponseCuesItem", +] diff --git a/src/roamhq/meeting/client.py b/src/roamhq/meeting/client.py new file mode 100644 index 0000000..7a72aa8 --- /dev/null +++ b/src/roamhq/meeting/client.py @@ -0,0 +1,1238 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawMeetingClient, RawMeetingClient +from .types.create_link_meeting_response import CreateLinkMeetingResponse +from .types.info_meeting_response import InfoMeetingResponse +from .types.link_info_meeting_response import LinkInfoMeetingResponse +from .types.list_meeting_response import ListMeetingResponse +from .types.participants_meeting_response import ParticipantsMeetingResponse +from .types.prompt_meeting_response import PromptMeetingResponse +from .types.search_meeting_response import SearchMeetingResponse +from .types.share_link_meeting_response import ShareLinkMeetingResponse +from .types.transcript_meeting_response import TranscriptMeetingResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class MeetingClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawMeetingClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawMeetingClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawMeetingClient + """ + return self._raw_client + + def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListMeetingResponse: + """ + List meetings, ordered newest-first. + + **Access:** Organization and Personal. Personal tokens return meetings the + authenticated user participated in. Organization tokens return every meeting + in the Roam only with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread); + without it, results are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return meetings that started before this time (RFC-3339). Sub-millisecond precision is truncated. + + after : typing.Optional[dt.datetime] + Only return meetings that started after this time (RFC-3339). Sub-millisecond precision is truncated. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + limit : typing.Optional[int] + Number of meetings to return per page. Capped to **10** when + `expand` includes `summary`, `actionItems`, or `chapters`, since + expanded payloads are substantially larger. + + expand : typing.Optional[str] + Comma-separated list of fields to inline on each meeting. Allowed + values are `summary`, `actionItems`, and `chapters` — same shape + as on [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). Use this to + avoid N+1 follow-up calls when scanning many recent meetings. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListMeetingResponse + Meetings retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.list() + """ + _response = self._raw_client.list( + before=before, after=after, cursor=cursor, limit=limit, expand=expand, request_options=request_options + ) + return _response.data + + def info( + self, + *, + id: str, + max_participants: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> InfoMeetingResponse: + """ + Get detailed information about a specific meeting, including AI-generated summary, action items, and chapters. + + Participants are included inline up to the `maxParticipants` limit. For meetings with more participants, use [`/meeting.participants`](https://developer.ro.am/docs/api/meeting-participants) to paginate through the full list. + + **Access:** Organization and Personal. Personal tokens are limited to meetings + the authenticated user participated in. Organization tokens without + [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread) + are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access; add `user:read` to include participants, `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + max_participants : typing.Optional[int] + Maximum number of participants to resolve and include inline. Use `/meeting.participants` for full pagination. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + InfoMeetingResponse + Meeting info retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.info( + id="id", + ) + """ + _response = self._raw_client.info(id=id, max_participants=max_participants, request_options=request_options) + return _response.data + + def participants( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ParticipantsMeetingResponse: + """ + Paginate through all participants of a meeting. This is the dedicated endpoint for retrieving the full participant list, complementing the capped inline participants in [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + Pagination uses an **opaque cursor** (not a row offset). Pass `nextCursor` + from a previous response as `cursor` to fetch the next page. Invalid cursors + return `error: "invalid_cursor"` — see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` and `user:read` (add `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + limit : typing.Optional[int] + Number of participants to return per page (default 50, max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not parse or construct cursors yourself. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ParticipantsMeetingResponse + Participants retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.participants( + id="id", + ) + """ + _response = self._raw_client.participants(id=id, limit=limit, cursor=cursor, request_options=request_options) + return _response.data + + def transcript( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> TranscriptMeetingResponse: + """ + Retrieve the transcript for a meeting. + + Supports content negotiation: + - **JSON** (default): Returns structured transcript with cues containing speaker IDs, text, and timing + - **WebVTT**: Set `Accept: text/vtt` header to receive standard WebVTT format with speaker voice tags + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + **Errors** (see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors)): + + | `error` code | Meaning | + |--------------|---------| + | `meeting_not_found` | Unknown or inaccessible meeting | + | `transcript_pending` | Not ready yet — retry later (may include `Retry-After`) | + | `transcript_unavailable` | Meeting was not transcribed — stop retrying | + | `upstream_timeout` | Timed out waiting on an upstream service — retry | + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TranscriptMeetingResponse + Transcript retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.transcript( + id="id", + ) + """ + _response = self._raw_client.transcript(id=id, request_options=request_options) + return _response.data + + def search( + self, + *, + query: str, + after: typing.Optional[dt.date] = None, + before: typing.Optional[dt.date] = None, + timezone: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> SearchMeetingResponse: + """ + AI-powered search across meeting transcripts and summaries. + + **Access:** Personal access only. Organization (account-level) tokens are not supported. + + **Required scope:** `meetings:read` + + Parameters + ---------- + query : str + Search query string. + + after : typing.Optional[dt.date] + Only return results from meetings after this date (YYYY-MM-DD). + + before : typing.Optional[dt.date] + Only return results from meetings before this date (YYYY-MM-DD). + + timezone : typing.Optional[str] + Timezone for date interpretation (e.g. "America/New_York"). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SearchMeetingResponse + Search results retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.search( + query="query", + ) + """ + _response = self._raw_client.search( + query=query, after=after, before=before, timezone=timezone, request_options=request_options + ) + return _response.data + + def prompt( + self, *, id: str, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> PromptMeetingResponse: + """ + Ask an AI question about a meeting's transcript content. Returns a natural language response based on the meeting transcript. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + prompt : str + The question to ask about the meeting. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PromptMeetingResponse + Response generated successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.prompt( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + prompt="What action items were assigned to Alex?", + ) + """ + _response = self._raw_client.prompt(id=id, prompt=prompt, request_options=request_options) + return _response.data + + def share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> ShareLinkMeetingResponse: + """ + Returns a shareable URL for a meeting that you can distribute to others. Pass the `id` of a meeting obtained from [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) or [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + This endpoint is **get-or-create**: it returns the meeting's existing share link, or mints one the first time it is called for that meeting. Repeat calls for the same meeting return the same URL. + + Creating a share link is a deliberate action, which is why it has its own endpoint rather than being returned as a field on `meeting.list` / `meeting.info` — fetching a meeting never mints a shareable link as a side effect. You can only create a share link for a meeting you can access; the same access check as `meeting.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ShareLinkMeetingResponse + The meeting's shareable link. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.share_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ) + """ + _response = self._raw_client.share_link(id=id, request_options=request_options) + return _response.data + + def create_link( + self, + *, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateLinkMeetingResponse: + """ + Create a meeting link. + + **Access:** Organization and Personal. In Organization mode, specify the host by email. In Personal mode, the host defaults to the authenticated user. + + **Required scope:** `meeting:write` or `meetinglink:write` + + Parameters + ---------- + name : str + Meeting Name + + host : typing.Optional[str] + Meeting Host Email, matching a member of your Roam. + + Required for Organization tokens. For Personal tokens, this is optional and defaults to the authenticated user. If provided with a Personal token, it must match the authenticated user's email. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateLinkMeetingResponse + Meeting link successfully created + + Examples + -------- + import datetime + + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.create_link( + name="Q1 Planning Session", + host="alex.chen@example.com", + start=datetime.datetime.fromisoformat( + "2026-02-15 14:00:00+00:00", + ), + end=datetime.datetime.fromisoformat( + "2026-02-15 15:00:00+00:00", + ), + ) + """ + _response = self._raw_client.create_link( + name=name, + host=host, + start=start, + end=end, + require_unconfirmed_email=require_unconfirmed_email, + request_options=request_options, + ) + return _response.data + + def link_info(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> LinkInfoMeetingResponse: + """ + Get a meeting link. + + **Access:** Organization and Personal. Personal tokens may only read meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:read` + + Parameters + ---------- + id : str + Meeting Link ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + LinkInfoMeetingResponse + Meeting link info + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.link_info( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ) + """ + _response = self._raw_client.link_info(id=id, request_options=request_options) + return _response.data + + def update_link( + self, + *, + id: str, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Update a meeting link. + + **Access:** Organization and Personal. Personal tokens may only update meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:write` + + Parameters + ---------- + id : str + Meeting Link ID + + name : str + Meeting Name + + host : typing.Optional[str] + (Optional) Meeting Host Email. + + The Host may NOT be updated. + As a result, this property may be omitted or empty. + If it is provided, it MUST match the existing value. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import datetime + + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meeting.update_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + name="Q1 Planning Session - Updated", + start=datetime.datetime.fromisoformat( + "2026-02-15 15:00:00+00:00", + ), + end=datetime.datetime.fromisoformat( + "2026-02-15 16:30:00+00:00", + ), + ) + """ + _response = self._raw_client.update_link( + id=id, + name=name, + host=host, + start=start, + end=end, + require_unconfirmed_email=require_unconfirmed_email, + request_options=request_options, + ) + return _response.data + + +class AsyncMeetingClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawMeetingClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawMeetingClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawMeetingClient + """ + return self._raw_client + + async def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListMeetingResponse: + """ + List meetings, ordered newest-first. + + **Access:** Organization and Personal. Personal tokens return meetings the + authenticated user participated in. Organization tokens return every meeting + in the Roam only with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread); + without it, results are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return meetings that started before this time (RFC-3339). Sub-millisecond precision is truncated. + + after : typing.Optional[dt.datetime] + Only return meetings that started after this time (RFC-3339). Sub-millisecond precision is truncated. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + limit : typing.Optional[int] + Number of meetings to return per page. Capped to **10** when + `expand` includes `summary`, `actionItems`, or `chapters`, since + expanded payloads are substantially larger. + + expand : typing.Optional[str] + Comma-separated list of fields to inline on each meeting. Allowed + values are `summary`, `actionItems`, and `chapters` — same shape + as on [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). Use this to + avoid N+1 follow-up calls when scanning many recent meetings. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListMeetingResponse + Meetings retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + before=before, after=after, cursor=cursor, limit=limit, expand=expand, request_options=request_options + ) + return _response.data + + async def info( + self, + *, + id: str, + max_participants: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> InfoMeetingResponse: + """ + Get detailed information about a specific meeting, including AI-generated summary, action items, and chapters. + + Participants are included inline up to the `maxParticipants` limit. For meetings with more participants, use [`/meeting.participants`](https://developer.ro.am/docs/api/meeting-participants) to paginate through the full list. + + **Access:** Organization and Personal. Personal tokens are limited to meetings + the authenticated user participated in. Organization tokens without + [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread) + are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access; add `user:read` to include participants, `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + max_participants : typing.Optional[int] + Maximum number of participants to resolve and include inline. Use `/meeting.participants` for full pagination. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + InfoMeetingResponse + Meeting info retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.info( + id="id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.info( + id=id, max_participants=max_participants, request_options=request_options + ) + return _response.data + + async def participants( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ParticipantsMeetingResponse: + """ + Paginate through all participants of a meeting. This is the dedicated endpoint for retrieving the full participant list, complementing the capped inline participants in [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + Pagination uses an **opaque cursor** (not a row offset). Pass `nextCursor` + from a previous response as `cursor` to fetch the next page. Invalid cursors + return `error: "invalid_cursor"` — see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` and `user:read` (add `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + limit : typing.Optional[int] + Number of participants to return per page (default 50, max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not parse or construct cursors yourself. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ParticipantsMeetingResponse + Participants retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.participants( + id="id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.participants( + id=id, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + async def transcript( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> TranscriptMeetingResponse: + """ + Retrieve the transcript for a meeting. + + Supports content negotiation: + - **JSON** (default): Returns structured transcript with cues containing speaker IDs, text, and timing + - **WebVTT**: Set `Accept: text/vtt` header to receive standard WebVTT format with speaker voice tags + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + **Errors** (see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors)): + + | `error` code | Meaning | + |--------------|---------| + | `meeting_not_found` | Unknown or inaccessible meeting | + | `transcript_pending` | Not ready yet — retry later (may include `Retry-After`) | + | `transcript_unavailable` | Meeting was not transcribed — stop retrying | + | `upstream_timeout` | Timed out waiting on an upstream service — retry | + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TranscriptMeetingResponse + Transcript retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.transcript( + id="id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.transcript(id=id, request_options=request_options) + return _response.data + + async def search( + self, + *, + query: str, + after: typing.Optional[dt.date] = None, + before: typing.Optional[dt.date] = None, + timezone: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> SearchMeetingResponse: + """ + AI-powered search across meeting transcripts and summaries. + + **Access:** Personal access only. Organization (account-level) tokens are not supported. + + **Required scope:** `meetings:read` + + Parameters + ---------- + query : str + Search query string. + + after : typing.Optional[dt.date] + Only return results from meetings after this date (YYYY-MM-DD). + + before : typing.Optional[dt.date] + Only return results from meetings before this date (YYYY-MM-DD). + + timezone : typing.Optional[str] + Timezone for date interpretation (e.g. "America/New_York"). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + SearchMeetingResponse + Search results retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.search( + query="query", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.search( + query=query, after=after, before=before, timezone=timezone, request_options=request_options + ) + return _response.data + + async def prompt( + self, *, id: str, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> PromptMeetingResponse: + """ + Ask an AI question about a meeting's transcript content. Returns a natural language response based on the meeting transcript. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + prompt : str + The question to ask about the meeting. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PromptMeetingResponse + Response generated successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.prompt( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + prompt="What action items were assigned to Alex?", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.prompt(id=id, prompt=prompt, request_options=request_options) + return _response.data + + async def share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> ShareLinkMeetingResponse: + """ + Returns a shareable URL for a meeting that you can distribute to others. Pass the `id` of a meeting obtained from [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) or [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + This endpoint is **get-or-create**: it returns the meeting's existing share link, or mints one the first time it is called for that meeting. Repeat calls for the same meeting return the same URL. + + Creating a share link is a deliberate action, which is why it has its own endpoint rather than being returned as a field on `meeting.list` / `meeting.info` — fetching a meeting never mints a shareable link as a side effect. You can only create a share link for a meeting you can access; the same access check as `meeting.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ShareLinkMeetingResponse + The meeting's shareable link. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.share_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.share_link(id=id, request_options=request_options) + return _response.data + + async def create_link( + self, + *, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateLinkMeetingResponse: + """ + Create a meeting link. + + **Access:** Organization and Personal. In Organization mode, specify the host by email. In Personal mode, the host defaults to the authenticated user. + + **Required scope:** `meeting:write` or `meetinglink:write` + + Parameters + ---------- + name : str + Meeting Name + + host : typing.Optional[str] + Meeting Host Email, matching a member of your Roam. + + Required for Organization tokens. For Personal tokens, this is optional and defaults to the authenticated user. If provided with a Personal token, it must match the authenticated user's email. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + CreateLinkMeetingResponse + Meeting link successfully created + + Examples + -------- + import asyncio + import datetime + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.create_link( + name="Q1 Planning Session", + host="alex.chen@example.com", + start=datetime.datetime.fromisoformat( + "2026-02-15 14:00:00+00:00", + ), + end=datetime.datetime.fromisoformat( + "2026-02-15 15:00:00+00:00", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.create_link( + name=name, + host=host, + start=start, + end=end, + require_unconfirmed_email=require_unconfirmed_email, + request_options=request_options, + ) + return _response.data + + async def link_info( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> LinkInfoMeetingResponse: + """ + Get a meeting link. + + **Access:** Organization and Personal. Personal tokens may only read meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:read` + + Parameters + ---------- + id : str + Meeting Link ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + LinkInfoMeetingResponse + Meeting link info + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.link_info( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.link_info(id=id, request_options=request_options) + return _response.data + + async def update_link( + self, + *, + id: str, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Update a meeting link. + + **Access:** Organization and Personal. Personal tokens may only update meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:write` + + Parameters + ---------- + id : str + Meeting Link ID + + name : str + Meeting Name + + host : typing.Optional[str] + (Optional) Meeting Host Email. + + The Host may NOT be updated. + As a result, this property may be omitted or empty. + If it is provided, it MUST match the existing value. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + import datetime + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meeting.update_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + name="Q1 Planning Session - Updated", + start=datetime.datetime.fromisoformat( + "2026-02-15 15:00:00+00:00", + ), + end=datetime.datetime.fromisoformat( + "2026-02-15 16:30:00+00:00", + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.update_link( + id=id, + name=name, + host=host, + start=start, + end=end, + require_unconfirmed_email=require_unconfirmed_email, + request_options=request_options, + ) + return _response.data diff --git a/src/roamhq/meeting/raw_client.py b/src/roamhq/meeting/raw_client.py new file mode 100644 index 0000000..c3d15a9 --- /dev/null +++ b/src/roamhq/meeting/raw_client.py @@ -0,0 +1,2616 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.datetime_utils import serialize_datetime +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.create_link_meeting_response import CreateLinkMeetingResponse +from .types.info_meeting_response import InfoMeetingResponse +from .types.link_info_meeting_response import LinkInfoMeetingResponse +from .types.list_meeting_response import ListMeetingResponse +from .types.participants_meeting_response import ParticipantsMeetingResponse +from .types.prompt_meeting_response import PromptMeetingResponse +from .types.search_meeting_response import SearchMeetingResponse +from .types.share_link_meeting_response import ShareLinkMeetingResponse +from .types.transcript_meeting_response import TranscriptMeetingResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawMeetingClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListMeetingResponse]: + """ + List meetings, ordered newest-first. + + **Access:** Organization and Personal. Personal tokens return meetings the + authenticated user participated in. Organization tokens return every meeting + in the Roam only with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread); + without it, results are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return meetings that started before this time (RFC-3339). Sub-millisecond precision is truncated. + + after : typing.Optional[dt.datetime] + Only return meetings that started after this time (RFC-3339). Sub-millisecond precision is truncated. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + limit : typing.Optional[int] + Number of meetings to return per page. Capped to **10** when + `expand` includes `summary`, `actionItems`, or `chapters`, since + expanded payloads are substantially larger. + + expand : typing.Optional[str] + Comma-separated list of fields to inline on each meeting. Allowed + values are `summary`, `actionItems`, and `chapters` — same shape + as on [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). Use this to + avoid N+1 follow-up calls when scanning many recent meetings. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListMeetingResponse] + Meetings retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.list", + method="GET", + params={ + "before": serialize_datetime(before) if before is not None else None, + "after": serialize_datetime(after) if after is not None else None, + "cursor": cursor, + "limit": limit, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListMeetingResponse, + parse_obj_as( + type_=ListMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def info( + self, + *, + id: str, + max_participants: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[InfoMeetingResponse]: + """ + Get detailed information about a specific meeting, including AI-generated summary, action items, and chapters. + + Participants are included inline up to the `maxParticipants` limit. For meetings with more participants, use [`/meeting.participants`](https://developer.ro.am/docs/api/meeting-participants) to paginate through the full list. + + **Access:** Organization and Personal. Personal tokens are limited to meetings + the authenticated user participated in. Organization tokens without + [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread) + are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access; add `user:read` to include participants, `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + max_participants : typing.Optional[int] + Maximum number of participants to resolve and include inline. Use `/meeting.participants` for full pagination. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[InfoMeetingResponse] + Meeting info retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.info", + method="GET", + params={ + "id": id, + "maxParticipants": max_participants, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + InfoMeetingResponse, + parse_obj_as( + type_=InfoMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def participants( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ParticipantsMeetingResponse]: + """ + Paginate through all participants of a meeting. This is the dedicated endpoint for retrieving the full participant list, complementing the capped inline participants in [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + Pagination uses an **opaque cursor** (not a row offset). Pass `nextCursor` + from a previous response as `cursor` to fetch the next page. Invalid cursors + return `error: "invalid_cursor"` — see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` and `user:read` (add `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + limit : typing.Optional[int] + Number of participants to return per page (default 50, max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not parse or construct cursors yourself. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ParticipantsMeetingResponse] + Participants retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.participants", + method="GET", + params={ + "id": id, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ParticipantsMeetingResponse, + parse_obj_as( + type_=ParticipantsMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def transcript( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[TranscriptMeetingResponse]: + """ + Retrieve the transcript for a meeting. + + Supports content negotiation: + - **JSON** (default): Returns structured transcript with cues containing speaker IDs, text, and timing + - **WebVTT**: Set `Accept: text/vtt` header to receive standard WebVTT format with speaker voice tags + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + **Errors** (see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors)): + + | `error` code | Meaning | + |--------------|---------| + | `meeting_not_found` | Unknown or inaccessible meeting | + | `transcript_pending` | Not ready yet — retry later (may include `Retry-After`) | + | `transcript_unavailable` | Meeting was not transcribed — stop retrying | + | `upstream_timeout` | Timed out waiting on an upstream service — retry | + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TranscriptMeetingResponse] + Transcript retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.transcript", + method="GET", + params={ + "id": id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TranscriptMeetingResponse, + parse_obj_as( + type_=TranscriptMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def search( + self, + *, + query: str, + after: typing.Optional[dt.date] = None, + before: typing.Optional[dt.date] = None, + timezone: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[SearchMeetingResponse]: + """ + AI-powered search across meeting transcripts and summaries. + + **Access:** Personal access only. Organization (account-level) tokens are not supported. + + **Required scope:** `meetings:read` + + Parameters + ---------- + query : str + Search query string. + + after : typing.Optional[dt.date] + Only return results from meetings after this date (YYYY-MM-DD). + + before : typing.Optional[dt.date] + Only return results from meetings before this date (YYYY-MM-DD). + + timezone : typing.Optional[str] + Timezone for date interpretation (e.g. "America/New_York"). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[SearchMeetingResponse] + Search results retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.search", + method="GET", + params={ + "query": query, + "after": str(after) if after is not None else None, + "before": str(before) if before is not None else None, + "timezone": timezone, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SearchMeetingResponse, + parse_obj_as( + type_=SearchMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def prompt( + self, *, id: str, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[PromptMeetingResponse]: + """ + Ask an AI question about a meeting's transcript content. Returns a natural language response based on the meeting transcript. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + prompt : str + The question to ask about the meeting. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[PromptMeetingResponse] + Response generated successfully + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.prompt", + method="POST", + json={ + "id": id, + "prompt": prompt, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PromptMeetingResponse, + parse_obj_as( + type_=PromptMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ShareLinkMeetingResponse]: + """ + Returns a shareable URL for a meeting that you can distribute to others. Pass the `id` of a meeting obtained from [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) or [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + This endpoint is **get-or-create**: it returns the meeting's existing share link, or mints one the first time it is called for that meeting. Repeat calls for the same meeting return the same URL. + + Creating a share link is a deliberate action, which is why it has its own endpoint rather than being returned as a field on `meeting.list` / `meeting.info` — fetching a meeting never mints a shareable link as a side effect. You can only create a share link for a meeting you can access; the same access check as `meeting.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ShareLinkMeetingResponse] + The meeting's shareable link. + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.shareLink", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ShareLinkMeetingResponse, + parse_obj_as( + type_=ShareLinkMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def create_link( + self, + *, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[CreateLinkMeetingResponse]: + """ + Create a meeting link. + + **Access:** Organization and Personal. In Organization mode, specify the host by email. In Personal mode, the host defaults to the authenticated user. + + **Required scope:** `meeting:write` or `meetinglink:write` + + Parameters + ---------- + name : str + Meeting Name + + host : typing.Optional[str] + Meeting Host Email, matching a member of your Roam. + + Required for Organization tokens. For Personal tokens, this is optional and defaults to the authenticated user. If provided with a Personal token, it must match the authenticated user's email. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[CreateLinkMeetingResponse] + Meeting link successfully created + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.link.create", + method="POST", + json={ + "name": name, + "host": host, + "start": start, + "end": end, + "requireUnconfirmedEmail": require_unconfirmed_email, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateLinkMeetingResponse, + parse_obj_as( + type_=CreateLinkMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def link_info( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[LinkInfoMeetingResponse]: + """ + Get a meeting link. + + **Access:** Organization and Personal. Personal tokens may only read meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:read` + + Parameters + ---------- + id : str + Meeting Link ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[LinkInfoMeetingResponse] + Meeting link info + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.link.info", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + LinkInfoMeetingResponse, + parse_obj_as( + type_=LinkInfoMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def update_link( + self, + *, + id: str, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[None]: + """ + Update a meeting link. + + **Access:** Organization and Personal. Personal tokens may only update meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:write` + + Parameters + ---------- + id : str + Meeting Link ID + + name : str + Meeting Name + + host : typing.Optional[str] + (Optional) Meeting Host Email. + + The Host may NOT be updated. + As a result, this property may be omitted or empty. + If it is provided, it MUST match the existing value. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "meeting.link.update", + method="POST", + json={ + "id": id, + "name": name, + "host": host, + "start": start, + "end": end, + "requireUnconfirmedEmail": require_unconfirmed_email, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawMeetingClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, + *, + before: typing.Optional[dt.datetime] = None, + after: typing.Optional[dt.datetime] = None, + cursor: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListMeetingResponse]: + """ + List meetings, ordered newest-first. + + **Access:** Organization and Personal. Personal tokens return meetings the + authenticated user participated in. Organization tokens return every meeting + in the Roam only with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread); + without it, results are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + + Parameters + ---------- + before : typing.Optional[dt.datetime] + Only return meetings that started before this time (RFC-3339). Sub-millisecond precision is truncated. + + after : typing.Optional[dt.datetime] + Only return meetings that started after this time (RFC-3339). Sub-millisecond precision is truncated. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + limit : typing.Optional[int] + Number of meetings to return per page. Capped to **10** when + `expand` includes `summary`, `actionItems`, or `chapters`, since + expanded payloads are substantially larger. + + expand : typing.Optional[str] + Comma-separated list of fields to inline on each meeting. Allowed + values are `summary`, `actionItems`, and `chapters` — same shape + as on [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). Use this to + avoid N+1 follow-up calls when scanning many recent meetings. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListMeetingResponse] + Meetings retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.list", + method="GET", + params={ + "before": serialize_datetime(before) if before is not None else None, + "after": serialize_datetime(after) if after is not None else None, + "cursor": cursor, + "limit": limit, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListMeetingResponse, + parse_obj_as( + type_=ListMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def info( + self, + *, + id: str, + max_participants: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[InfoMeetingResponse]: + """ + Get detailed information about a specific meeting, including AI-generated summary, action items, and chapters. + + Participants are included inline up to the `maxParticipants` limit. For meetings with more participants, use [`/meeting.participants`](https://developer.ro.am/docs/api/meeting-participants) to paginate through the full list. + + **Access:** Organization and Personal. Personal tokens are limited to meetings + the authenticated user participated in. Organization tokens without + [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread) + are limited to meetings the install's bot has access to. + + **Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access; add `user:read` to include participants, `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + max_participants : typing.Optional[int] + Maximum number of participants to resolve and include inline. Use `/meeting.participants` for full pagination. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[InfoMeetingResponse] + Meeting info retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.info", + method="GET", + params={ + "id": id, + "maxParticipants": max_participants, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + InfoMeetingResponse, + parse_obj_as( + type_=InfoMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def participants( + self, + *, + id: str, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ParticipantsMeetingResponse]: + """ + Paginate through all participants of a meeting. This is the dedicated endpoint for retrieving the full participant list, complementing the capped inline participants in [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + Pagination uses an **opaque cursor** (not a row offset). Pass `nextCursor` + from a previous response as `cursor` to fetch the next page. Invalid cursors + return `error: "invalid_cursor"` — see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` and `user:read` (add `user:read.email` for participant emails) + + Parameters + ---------- + id : str + The meeting ID. + + limit : typing.Optional[int] + Number of participants to return per page (default 50, max 200). + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not parse or construct cursors yourself. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ParticipantsMeetingResponse] + Participants retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.participants", + method="GET", + params={ + "id": id, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ParticipantsMeetingResponse, + parse_obj_as( + type_=ParticipantsMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def transcript( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[TranscriptMeetingResponse]: + """ + Retrieve the transcript for a meeting. + + Supports content negotiation: + - **JSON** (default): Returns structured transcript with cues containing speaker IDs, text, and timing + - **WebVTT**: Set `Accept: text/vtt` header to receive standard WebVTT format with speaker voice tags + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + **Errors** (see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors)): + + | `error` code | Meaning | + |--------------|---------| + | `meeting_not_found` | Unknown or inaccessible meeting | + | `transcript_pending` | Not ready yet — retry later (may include `Retry-After`) | + | `transcript_unavailable` | Meeting was not transcribed — stop retrying | + | `upstream_timeout` | Timed out waiting on an upstream service — retry | + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TranscriptMeetingResponse] + Transcript retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.transcript", + method="GET", + params={ + "id": id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TranscriptMeetingResponse, + parse_obj_as( + type_=TranscriptMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def search( + self, + *, + query: str, + after: typing.Optional[dt.date] = None, + before: typing.Optional[dt.date] = None, + timezone: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[SearchMeetingResponse]: + """ + AI-powered search across meeting transcripts and summaries. + + **Access:** Personal access only. Organization (account-level) tokens are not supported. + + **Required scope:** `meetings:read` + + Parameters + ---------- + query : str + Search query string. + + after : typing.Optional[dt.date] + Only return results from meetings after this date (YYYY-MM-DD). + + before : typing.Optional[dt.date] + Only return results from meetings before this date (YYYY-MM-DD). + + timezone : typing.Optional[str] + Timezone for date interpretation (e.g. "America/New_York"). + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[SearchMeetingResponse] + Search results retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.search", + method="GET", + params={ + "query": query, + "after": str(after) if after is not None else None, + "before": str(before) if before is not None else None, + "timezone": timezone, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + SearchMeetingResponse, + parse_obj_as( + type_=SearchMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def prompt( + self, *, id: str, prompt: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[PromptMeetingResponse]: + """ + Ask an AI question about a meeting's transcript content. Returns a natural language response based on the meeting transcript. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + prompt : str + The question to ask about the meeting. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[PromptMeetingResponse] + Response generated successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.prompt", + method="POST", + json={ + "id": id, + "prompt": prompt, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PromptMeetingResponse, + parse_obj_as( + type_=PromptMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def share_link( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ShareLinkMeetingResponse]: + """ + Returns a shareable URL for a meeting that you can distribute to others. Pass the `id` of a meeting obtained from [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) or [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + + This endpoint is **get-or-create**: it returns the meeting's existing share link, or mints one the first time it is called for that meeting. Repeat calls for the same meeting return the same URL. + + Creating a share link is a deliberate action, which is why it has its own endpoint rather than being returned as a field on `meeting.list` / `meeting.info` — fetching a meeting never mints a shareable link as a side effect. You can only create a share link for a meeting you can access; the same access check as `meeting.info` applies. + + **Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + + **Required scope:** `meetings:read` + + Parameters + ---------- + id : str + The meeting ID. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ShareLinkMeetingResponse] + The meeting's shareable link. + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.shareLink", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ShareLinkMeetingResponse, + parse_obj_as( + type_=ShareLinkMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def create_link( + self, + *, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[CreateLinkMeetingResponse]: + """ + Create a meeting link. + + **Access:** Organization and Personal. In Organization mode, specify the host by email. In Personal mode, the host defaults to the authenticated user. + + **Required scope:** `meeting:write` or `meetinglink:write` + + Parameters + ---------- + name : str + Meeting Name + + host : typing.Optional[str] + Meeting Host Email, matching a member of your Roam. + + Required for Organization tokens. For Personal tokens, this is optional and defaults to the authenticated user. If provided with a Personal token, it must match the authenticated user's email. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[CreateLinkMeetingResponse] + Meeting link successfully created + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.link.create", + method="POST", + json={ + "name": name, + "host": host, + "start": start, + "end": end, + "requireUnconfirmedEmail": require_unconfirmed_email, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + CreateLinkMeetingResponse, + parse_obj_as( + type_=CreateLinkMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def link_info( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[LinkInfoMeetingResponse]: + """ + Get a meeting link. + + **Access:** Organization and Personal. Personal tokens may only read meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:read` + + Parameters + ---------- + id : str + Meeting Link ID + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[LinkInfoMeetingResponse] + Meeting link info + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.link.info", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + LinkInfoMeetingResponse, + parse_obj_as( + type_=LinkInfoMeetingResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def update_link( + self, + *, + id: str, + name: str, + host: typing.Optional[str] = OMIT, + start: typing.Optional[dt.datetime] = OMIT, + end: typing.Optional[dt.datetime] = OMIT, + require_unconfirmed_email: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[None]: + """ + Update a meeting link. + + **Access:** Organization and Personal. Personal tokens may only update meeting links where the authenticated user is the host. + + **Required scope:** `meetinglink:write` + + Parameters + ---------- + id : str + Meeting Link ID + + name : str + Meeting Name + + host : typing.Optional[str] + (Optional) Meeting Host Email. + + The Host may NOT be updated. + As a result, this property may be omitted or empty. + If it is provided, it MUST match the existing value. + + start : typing.Optional[dt.datetime] + (Optional) Meeting start time in RFC3339. + + end : typing.Optional[dt.datetime] + (Optional) Meeting end time in RFC3339. + + require_unconfirmed_email : typing.Optional[bool] + (Optional) If true, guests must verify ownership of their email address before joining. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "meeting.link.update", + method="POST", + json={ + "id": id, + "name": name, + "host": host, + "start": start, + "end": end, + "requireUnconfirmedEmail": require_unconfirmed_email, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/meeting/types/__init__.py b/src/roamhq/meeting/types/__init__.py new file mode 100644 index 0000000..0a8de46 --- /dev/null +++ b/src/roamhq/meeting/types/__init__.py @@ -0,0 +1,88 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .create_link_meeting_response import CreateLinkMeetingResponse + from .info_meeting_response import InfoMeetingResponse + from .info_meeting_response_chapters_item import InfoMeetingResponseChaptersItem + from .info_meeting_response_video_status import InfoMeetingResponseVideoStatus + from .link_info_meeting_response import LinkInfoMeetingResponse + from .list_meeting_response import ListMeetingResponse + from .list_meeting_response_meetings_item import ListMeetingResponseMeetingsItem + from .list_meeting_response_meetings_item_chapters_item import ListMeetingResponseMeetingsItemChaptersItem + from .list_meeting_response_meetings_item_video_status import ListMeetingResponseMeetingsItemVideoStatus + from .participants_meeting_response import ParticipantsMeetingResponse + from .prompt_meeting_response import PromptMeetingResponse + from .search_meeting_response import SearchMeetingResponse + from .search_meeting_response_inferred_filter import SearchMeetingResponseInferredFilter + from .search_meeting_response_results_item import SearchMeetingResponseResultsItem + from .share_link_meeting_response import ShareLinkMeetingResponse + from .transcript_meeting_response import TranscriptMeetingResponse + from .transcript_meeting_response_cues_item import TranscriptMeetingResponseCuesItem +_dynamic_imports: typing.Dict[str, str] = { + "CreateLinkMeetingResponse": ".create_link_meeting_response", + "InfoMeetingResponse": ".info_meeting_response", + "InfoMeetingResponseChaptersItem": ".info_meeting_response_chapters_item", + "InfoMeetingResponseVideoStatus": ".info_meeting_response_video_status", + "LinkInfoMeetingResponse": ".link_info_meeting_response", + "ListMeetingResponse": ".list_meeting_response", + "ListMeetingResponseMeetingsItem": ".list_meeting_response_meetings_item", + "ListMeetingResponseMeetingsItemChaptersItem": ".list_meeting_response_meetings_item_chapters_item", + "ListMeetingResponseMeetingsItemVideoStatus": ".list_meeting_response_meetings_item_video_status", + "ParticipantsMeetingResponse": ".participants_meeting_response", + "PromptMeetingResponse": ".prompt_meeting_response", + "SearchMeetingResponse": ".search_meeting_response", + "SearchMeetingResponseInferredFilter": ".search_meeting_response_inferred_filter", + "SearchMeetingResponseResultsItem": ".search_meeting_response_results_item", + "ShareLinkMeetingResponse": ".share_link_meeting_response", + "TranscriptMeetingResponse": ".transcript_meeting_response", + "TranscriptMeetingResponseCuesItem": ".transcript_meeting_response_cues_item", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "CreateLinkMeetingResponse", + "InfoMeetingResponse", + "InfoMeetingResponseChaptersItem", + "InfoMeetingResponseVideoStatus", + "LinkInfoMeetingResponse", + "ListMeetingResponse", + "ListMeetingResponseMeetingsItem", + "ListMeetingResponseMeetingsItemChaptersItem", + "ListMeetingResponseMeetingsItemVideoStatus", + "ParticipantsMeetingResponse", + "PromptMeetingResponse", + "SearchMeetingResponse", + "SearchMeetingResponseInferredFilter", + "SearchMeetingResponseResultsItem", + "ShareLinkMeetingResponse", + "TranscriptMeetingResponse", + "TranscriptMeetingResponseCuesItem", +] diff --git a/src/roamhq/meeting/types/create_link_meeting_response.py b/src/roamhq/meeting/types/create_link_meeting_response.py new file mode 100644 index 0000000..12164de --- /dev/null +++ b/src/roamhq/meeting/types/create_link_meeting_response.py @@ -0,0 +1,29 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class CreateLinkMeetingResponse(UniversalBaseModel): + id: typing.Optional[str] = pydantic.Field(default=None) + """ + Meeting link ID + """ + + url: typing.Optional[str] = pydantic.Field(default=None) + """ + Meeting link URL + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/info_meeting_response.py b/src/roamhq/meeting/types/info_meeting_response.py new file mode 100644 index 0000000..d87b60d --- /dev/null +++ b/src/roamhq/meeting/types/info_meeting_response.py @@ -0,0 +1,144 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.action_item import ActionItem +from ...types.meeting_participant import MeetingParticipant +from .info_meeting_response_chapters_item import InfoMeetingResponseChaptersItem +from .info_meeting_response_video_status import InfoMeetingResponseVideoStatus + + +class InfoMeetingResponse(UniversalBaseModel): + id: str = pydantic.Field() + """ + Meeting ID + """ + + title: str = pydantic.Field() + """ + Meeting title + """ + + subtitle: typing.Optional[str] = pydantic.Field(default=None) + """ + Meeting subtitle + """ + + start: dt.datetime = pydantic.Field() + """ + Meeting start time (RFC-3339) + """ + + end: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + Meeting end time (RFC-3339). Omitted while the meeting is still in progress. + """ + + participant_count: typing_extensions.Annotated[ + int, + FieldMetadata(alias="participantCount"), + pydantic.Field(alias="participantCount", description="Total number of participants"), + ] + """ + Total number of participants + """ + + has_video: typing_extensions.Annotated[ + bool, + FieldMetadata(alias="hasVideo"), + pydantic.Field( + alias="hasVideo", + description='Whether the meeting was video recorded — a video track exists.\n`true` from the moment recording starts, `true` at meeting end, and\nit never flips back. It does **not** mean the recording is ready to\nfetch or play; read `videoStatus` for that. Matches `meeting.list`,\nthe `meeting.ended` webhook `data.hasVideo`, and the\n`{"hasVideo": true}` subscription filter.', + ), + ] + """ + Whether the meeting was video recorded — a video track exists. + `true` from the moment recording starts, `true` at meeting end, and + it never flips back. It does **not** mean the recording is ready to + fetch or play; read `videoStatus` for that. Matches `meeting.list`, + the `meeting.ended` webhook `data.hasVideo`, and the + `{"hasVideo": true}` subscription filter. + """ + + video_status: typing_extensions.Annotated[ + InfoMeetingResponseVideoStatus, + FieldMetadata(alias="videoStatus"), + pydantic.Field( + alias="videoStatus", + description="Where the meeting's video recording is, which — unlike `hasVideo` —\nchanges over time:\n\n- `none` — no video track. The meeting was not recorded and no\n recording will appear later. Always paired with `hasVideo: false`.\n- `processing` — a recording exists but its upload has not finished,\n so there is nothing to play yet. Call `/meeting.info` again\n shortly.\n- `available` — the recording is uploaded and has an asset. Use\n [`/meeting.shareLink`](https://developer.ro.am/docs/api/meeting-share-link) to get a\n shareable link to it.\n\nNot present on the `meeting.ended` webhook payload — see that\nevent's page.", + ), + ] + """ + Where the meeting's video recording is, which — unlike `hasVideo` — + changes over time: + + - `none` — no video track. The meeting was not recorded and no + recording will appear later. Always paired with `hasVideo: false`. + - `processing` — a recording exists but its upload has not finished, + so there is nothing to play yet. Call `/meeting.info` again + shortly. + - `available` — the recording is uploaded and has an asset. Use + [`/meeting.shareLink`](https://developer.ro.am/docs/api/meeting-share-link) to get a + shareable link to it. + + Not present on the `meeting.ended` webhook payload — see that + event's page. + """ + + host: typing.Optional[MeetingParticipant] = pydantic.Field(default=None) + """ + Meeting host as a participant object. Requires `user:read` + scope; emails are only included with `user:read.email`. + Omitted when the host cannot be resolved. + """ + + participants: typing.Optional[typing.List[MeetingParticipant]] = pydantic.Field(default=None) + """ + Resolved participants (up to `maxParticipants`). Requires `user:read` scope. + """ + + participants_omitted: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="participantsOmitted"), + pydantic.Field( + alias="participantsOmitted", description="True if total participants exceeds the resolved count" + ), + ] = None + """ + True if total participants exceeds the resolved count + """ + + summary: typing.Optional[str] = pydantic.Field(default=None) + """ + AI-generated meeting summary + """ + + action_items: typing_extensions.Annotated[ + typing.Optional[typing.List[ActionItem]], + FieldMetadata(alias="actionItems"), + pydantic.Field(alias="actionItems", description="AI-extracted action items"), + ] = None + """ + AI-extracted action items + """ + + chapters: typing.Optional[typing.List[InfoMeetingResponseChaptersItem]] = pydantic.Field(default=None) + """ + Meeting chapters/segments + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/info_meeting_response_chapters_item.py b/src/roamhq/meeting/types/info_meeting_response_chapters_item.py new file mode 100644 index 0000000..c0b2e59 --- /dev/null +++ b/src/roamhq/meeting/types/info_meeting_response_chapters_item.py @@ -0,0 +1,34 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class InfoMeetingResponseChaptersItem(UniversalBaseModel): + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Chapter name + """ + + start: typing.Optional[int] = pydantic.Field(default=None) + """ + Chapter start offset in milliseconds since the meeting's `start`. + """ + + synopsis: typing.Optional[str] = pydantic.Field(default=None) + """ + Brief synopsis of the chapter + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/info_meeting_response_video_status.py b/src/roamhq/meeting/types/info_meeting_response_video_status.py new file mode 100644 index 0000000..445b04a --- /dev/null +++ b/src/roamhq/meeting/types/info_meeting_response_video_status.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +InfoMeetingResponseVideoStatus = typing.Union[typing.Literal["none", "processing", "available"], typing.Any] diff --git a/src/roamhq/meeting/types/link_info_meeting_response.py b/src/roamhq/meeting/types/link_info_meeting_response.py new file mode 100644 index 0000000..1afcf15 --- /dev/null +++ b/src/roamhq/meeting/types/link_info_meeting_response.py @@ -0,0 +1,64 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class LinkInfoMeetingResponse(UniversalBaseModel): + id: str = pydantic.Field() + """ + Meeting Link ID + """ + + name: str = pydantic.Field() + """ + Meeting Name + """ + + host: str = pydantic.Field() + """ + Meeting Host Email, matching a member of your Roam. + """ + + start: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + (Optional) Meeting start time in RFC3339. + """ + + end: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + (Optional) Meeting end time in RFC3339. + """ + + url: str = pydantic.Field() + """ + Meeting link URL + """ + + require_unconfirmed_email: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="requireUnconfirmedEmail"), + pydantic.Field( + alias="requireUnconfirmedEmail", + description="Whether attendees joining with an unconfirmed email are required to verify it before joining.", + ), + ] = None + """ + Whether attendees joining with an unconfirmed email are required to verify it before joining. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/list_meeting_response.py b/src/roamhq/meeting/types/list_meeting_response.py new file mode 100644 index 0000000..6d7c7c0 --- /dev/null +++ b/src/roamhq/meeting/types/list_meeting_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .list_meeting_response_meetings_item import ListMeetingResponseMeetingsItem + + +class ListMeetingResponse(UniversalBaseModel): + meetings: typing.Optional[typing.List[ListMeetingResponseMeetingsItem]] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Pagination cursor for the next page"), + ] = None + """ + Pagination cursor for the next page + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/list_meeting_response_meetings_item.py b/src/roamhq/meeting/types/list_meeting_response_meetings_item.py new file mode 100644 index 0000000..9f897d2 --- /dev/null +++ b/src/roamhq/meeting/types/list_meeting_response_meetings_item.py @@ -0,0 +1,126 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.action_item import ActionItem +from ...types.meeting_participant import MeetingParticipant +from .list_meeting_response_meetings_item_chapters_item import ListMeetingResponseMeetingsItemChaptersItem +from .list_meeting_response_meetings_item_video_status import ListMeetingResponseMeetingsItemVideoStatus + + +class ListMeetingResponseMeetingsItem(UniversalBaseModel): + id: str = pydantic.Field() + """ + Meeting ID + """ + + title: str = pydantic.Field() + """ + Meeting title + """ + + subtitle: typing.Optional[str] = pydantic.Field(default=None) + """ + Meeting subtitle + """ + + start: dt.datetime = pydantic.Field() + """ + Meeting start time (RFC-3339) + """ + + participant_count: typing_extensions.Annotated[ + int, + FieldMetadata(alias="participantCount"), + pydantic.Field(alias="participantCount", description="Total number of participants"), + ] + """ + Total number of participants + """ + + has_video: typing_extensions.Annotated[ + bool, + FieldMetadata(alias="hasVideo"), + pydantic.Field( + alias="hasVideo", + description="Whether the meeting was video recorded — a video track\nexists. `true` from the moment recording starts and it\nnever flips back. It does **not** mean the recording is\nready to fetch or play; read `videoStatus` for that.\nMatches [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) and\nthe `meeting.ended` webhook `data.hasVideo`.", + ), + ] + """ + Whether the meeting was video recorded — a video track + exists. `true` from the moment recording starts and it + never flips back. It does **not** mean the recording is + ready to fetch or play; read `videoStatus` for that. + Matches [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) and + the `meeting.ended` webhook `data.hasVideo`. + """ + + video_status: typing_extensions.Annotated[ + ListMeetingResponseMeetingsItemVideoStatus, + FieldMetadata(alias="videoStatus"), + pydantic.Field( + alias="videoStatus", + description="Where this meeting's video recording is, which — unlike\n`hasVideo` — changes over time:\n\n- `none` — no video track; the meeting was not recorded.\n Always paired with `hasVideo: false`.\n- `processing` — a recording exists but its upload has\n not finished, so there is nothing to play yet. List\n again shortly, or call\n [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) for that one\n meeting.\n- `available` — the recording is uploaded and has an\n asset. Use\n [`/meeting.shareLink`](https://developer.ro.am/docs/api/meeting-share-link)\n to get a shareable link to it.", + ), + ] + """ + Where this meeting's video recording is, which — unlike + `hasVideo` — changes over time: + + - `none` — no video track; the meeting was not recorded. + Always paired with `hasVideo: false`. + - `processing` — a recording exists but its upload has + not finished, so there is nothing to play yet. List + again shortly, or call + [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) for that one + meeting. + - `available` — the recording is uploaded and has an + asset. Use + [`/meeting.shareLink`](https://developer.ro.am/docs/api/meeting-share-link) + to get a shareable link to it. + """ + + host: typing.Optional[MeetingParticipant] = pydantic.Field(default=None) + """ + Meeting host as a participant object. Requires + `user:read` scope; emails are only included with + `user:read.email`. Omitted when the host cannot be + resolved. + """ + + summary: typing.Optional[str] = pydantic.Field(default=None) + """ + AI-generated meeting summary. Only present when `expand=summary`. + """ + + action_items: typing_extensions.Annotated[ + typing.Optional[typing.List[ActionItem]], + FieldMetadata(alias="actionItems"), + pydantic.Field( + alias="actionItems", description="AI-extracted action items. Only present when `expand=actionItems`." + ), + ] = None + """ + AI-extracted action items. Only present when `expand=actionItems`. + """ + + chapters: typing.Optional[typing.List[ListMeetingResponseMeetingsItemChaptersItem]] = pydantic.Field(default=None) + """ + Meeting chapters/segments. Only present when `expand=chapters`. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/list_meeting_response_meetings_item_chapters_item.py b/src/roamhq/meeting/types/list_meeting_response_meetings_item_chapters_item.py new file mode 100644 index 0000000..c912ca7 --- /dev/null +++ b/src/roamhq/meeting/types/list_meeting_response_meetings_item_chapters_item.py @@ -0,0 +1,27 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class ListMeetingResponseMeetingsItemChaptersItem(UniversalBaseModel): + name: typing.Optional[str] = None + start: typing.Optional[int] = pydantic.Field(default=None) + """ + Offset in milliseconds since the meeting's `start`. + """ + + synopsis: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/list_meeting_response_meetings_item_video_status.py b/src/roamhq/meeting/types/list_meeting_response_meetings_item_video_status.py new file mode 100644 index 0000000..8dc862d --- /dev/null +++ b/src/roamhq/meeting/types/list_meeting_response_meetings_item_video_status.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ListMeetingResponseMeetingsItemVideoStatus = typing.Union[typing.Literal["none", "processing", "available"], typing.Any] diff --git a/src/roamhq/meeting/types/participants_meeting_response.py b/src/roamhq/meeting/types/participants_meeting_response.py new file mode 100644 index 0000000..3088896 --- /dev/null +++ b/src/roamhq/meeting/types/participants_meeting_response.py @@ -0,0 +1,41 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.meeting_participant import MeetingParticipant + + +class ParticipantsMeetingResponse(UniversalBaseModel): + ok: typing.Optional[bool] = None + participants: typing.List[MeetingParticipant] + total: int = pydantic.Field() + """ + Total number of participants in the meeting + """ + + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field( + alias="nextCursor", + description="Present when more participants remain. Pass as `cursor` on the next request. Omitted on the last page.", + ), + ] = None + """ + Present when more participants remain. Pass as `cursor` on the next request. Omitted on the last page. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/prompt_meeting_response.py b/src/roamhq/meeting/types/prompt_meeting_response.py new file mode 100644 index 0000000..e8fd88b --- /dev/null +++ b/src/roamhq/meeting/types/prompt_meeting_response.py @@ -0,0 +1,24 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class PromptMeetingResponse(UniversalBaseModel): + response: str = pydantic.Field() + """ + AI-generated response to the prompt + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/search_meeting_response.py b/src/roamhq/meeting/types/search_meeting_response.py new file mode 100644 index 0000000..59afabb --- /dev/null +++ b/src/roamhq/meeting/types/search_meeting_response.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .search_meeting_response_inferred_filter import SearchMeetingResponseInferredFilter +from .search_meeting_response_results_item import SearchMeetingResponseResultsItem + + +class SearchMeetingResponse(UniversalBaseModel): + results: typing.Optional[typing.List[SearchMeetingResponseResultsItem]] = None + inferred_filter: typing_extensions.Annotated[ + typing.Optional[SearchMeetingResponseInferredFilter], + FieldMetadata(alias="inferredFilter"), + pydantic.Field(alias="inferredFilter", description="AI-inferred search filters"), + ] = None + """ + AI-inferred search filters + """ + + inferred_query: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="inferredQuery"), + pydantic.Field(alias="inferredQuery", description="AI-refined search query"), + ] = None + """ + AI-refined search query + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/search_meeting_response_inferred_filter.py b/src/roamhq/meeting/types/search_meeting_response_inferred_filter.py new file mode 100644 index 0000000..37ba45a --- /dev/null +++ b/src/roamhq/meeting/types/search_meeting_response_inferred_filter.py @@ -0,0 +1,38 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class SearchMeetingResponseInferredFilter(UniversalBaseModel): + """ + AI-inferred search filters + """ + + after: typing.Optional[str] = pydantic.Field(default=None) + """ + Inferred start date (YYYY-MM-DD) + """ + + before: typing.Optional[str] = pydantic.Field(default=None) + """ + Inferred end date (YYYY-MM-DD) + """ + + attendees: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Inferred attendee filter + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/search_meeting_response_results_item.py b/src/roamhq/meeting/types/search_meeting_response_results_item.py new file mode 100644 index 0000000..9269955 --- /dev/null +++ b/src/roamhq/meeting/types/search_meeting_response_results_item.py @@ -0,0 +1,72 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class SearchMeetingResponseResultsItem(UniversalBaseModel): + meeting_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="meetingId"), + pydantic.Field(alias="meetingId", description="Meeting ID"), + ] = None + """ + Meeting ID + """ + + meeting_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="meetingName"), + pydantic.Field(alias="meetingName", description="Meeting title"), + ] = None + """ + Meeting title + """ + + meeting_date: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="meetingDate"), + pydantic.Field(alias="meetingDate", description="Meeting date (RFC-3339)"), + ] = None + """ + Meeting date (RFC-3339) + """ + + participants: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Participant names + """ + + highlighted_summary: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="highlightedSummary"), + pydantic.Field(alias="highlightedSummary", description="Relevant summary excerpt"), + ] = None + """ + Relevant summary excerpt + """ + + highlighted_transcript: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="highlightedTranscript"), + pydantic.Field(alias="highlightedTranscript", description="Relevant transcript excerpt"), + ] = None + """ + Relevant transcript excerpt + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/share_link_meeting_response.py b/src/roamhq/meeting/types/share_link_meeting_response.py new file mode 100644 index 0000000..0a20e9f --- /dev/null +++ b/src/roamhq/meeting/types/share_link_meeting_response.py @@ -0,0 +1,29 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class ShareLinkMeetingResponse(UniversalBaseModel): + id: str = pydantic.Field() + """ + The meeting ID. + """ + + url: str = pydantic.Field() + """ + The shareable meeting URL. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/transcript_meeting_response.py b/src/roamhq/meeting/types/transcript_meeting_response.py new file mode 100644 index 0000000..dabb2f2 --- /dev/null +++ b/src/roamhq/meeting/types/transcript_meeting_response.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .transcript_meeting_response_cues_item import TranscriptMeetingResponseCuesItem + + +class TranscriptMeetingResponse(UniversalBaseModel): + id: str = pydantic.Field() + """ + Meeting ID + """ + + cues: typing.List[TranscriptMeetingResponseCuesItem] = pydantic.Field() + """ + Transcript cues in chronological order + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meeting/types/transcript_meeting_response_cues_item.py b/src/roamhq/meeting/types/transcript_meeting_response_cues_item.py new file mode 100644 index 0000000..0329331 --- /dev/null +++ b/src/roamhq/meeting/types/transcript_meeting_response_cues_item.py @@ -0,0 +1,45 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class TranscriptMeetingResponseCuesItem(UniversalBaseModel): + speaker_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="speakerId"), + pydantic.Field(alias="speakerId", description="Address ID of the speaker"), + ] = None + """ + Address ID of the speaker + """ + + text: str = pydantic.Field() + """ + Spoken text + """ + + start: int = pydantic.Field() + """ + Start time in milliseconds from meeting start + """ + + end: int = pydantic.Field() + """ + End time in milliseconds from meeting start + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meetings/__init__.py b/src/roamhq/meetings/__init__.py new file mode 100644 index 0000000..142ab30 --- /dev/null +++ b/src/roamhq/meetings/__init__.py @@ -0,0 +1,39 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import RecordingListResponse, RecordingListResponseRecordingsItem +_dynamic_imports: typing.Dict[str, str] = { + "RecordingListResponse": ".types", + "RecordingListResponseRecordingsItem": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["RecordingListResponse", "RecordingListResponseRecordingsItem"] diff --git a/src/roamhq/meetings/client.py b/src/roamhq/meetings/client.py new file mode 100644 index 0000000..bf8f181 --- /dev/null +++ b/src/roamhq/meetings/client.py @@ -0,0 +1,202 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawMeetingsClient, RawMeetingsClient +from .types.recording_list_response import RecordingListResponse + + +class MeetingsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawMeetingsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawMeetingsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawMeetingsClient + """ + return self._raw_client + + def recording_list( + self, + *, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> RecordingListResponse: + """ + **Legacy:** Prefer [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) / + [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) for new integrations. + + Lists recordings in your home Roam, filtered by date range (after/before). + Organization clients without roam-wide meeting access + ([`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)) + receive `403`; use [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead. + This route remains registered for existing callers. It returns v0-style + identifiers and is not a v1 media-download path. + + The plural alias `/recordings.list` is also registered for existing callers; + use this singular form in new documentation and tooling. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent recordings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization only. Requires roam-wide meeting access. + + **Required scope:** `recordings:read` and `admin:meetings:read` (or a grandfathered roam-wide API key) + + Parameters + ---------- + after : typing.Optional[str] + The datetime to begin listing recordings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[str] + The datetime until which to list recordings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of recordings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + RecordingListResponse + OK + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.meetings.recording_list() + """ + _response = self._raw_client.recording_list( + after=after, before=before, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data + + +class AsyncMeetingsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawMeetingsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawMeetingsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawMeetingsClient + """ + return self._raw_client + + async def recording_list( + self, + *, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> RecordingListResponse: + """ + **Legacy:** Prefer [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) / + [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) for new integrations. + + Lists recordings in your home Roam, filtered by date range (after/before). + Organization clients without roam-wide meeting access + ([`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)) + receive `403`; use [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead. + This route remains registered for existing callers. It returns v0-style + identifiers and is not a v1 media-download path. + + The plural alias `/recordings.list` is also registered for existing callers; + use this singular form in new documentation and tooling. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent recordings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization only. Requires roam-wide meeting access. + + **Required scope:** `recordings:read` and `admin:meetings:read` (or a grandfathered roam-wide API key) + + Parameters + ---------- + after : typing.Optional[str] + The datetime to begin listing recordings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[str] + The datetime until which to list recordings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of recordings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + RecordingListResponse + OK + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.meetings.recording_list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.recording_list( + after=after, before=before, limit=limit, cursor=cursor, request_options=request_options + ) + return _response.data diff --git a/src/roamhq/meetings/raw_client.py b/src/roamhq/meetings/raw_client.py new file mode 100644 index 0000000..b457ef2 --- /dev/null +++ b/src/roamhq/meetings/raw_client.py @@ -0,0 +1,277 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.internal_server_error import InternalServerError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.recording_list_response import RecordingListResponse +from pydantic import ValidationError + + +class RawMeetingsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def recording_list( + self, + *, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[RecordingListResponse]: + """ + **Legacy:** Prefer [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) / + [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) for new integrations. + + Lists recordings in your home Roam, filtered by date range (after/before). + Organization clients without roam-wide meeting access + ([`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)) + receive `403`; use [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead. + This route remains registered for existing callers. It returns v0-style + identifiers and is not a v1 media-download path. + + The plural alias `/recordings.list` is also registered for existing callers; + use this singular form in new documentation and tooling. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent recordings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization only. Requires roam-wide meeting access. + + **Required scope:** `recordings:read` and `admin:meetings:read` (or a grandfathered roam-wide API key) + + Parameters + ---------- + after : typing.Optional[str] + The datetime to begin listing recordings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[str] + The datetime until which to list recordings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of recordings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[RecordingListResponse] + OK + """ + _response = self._client_wrapper.httpx_client.request( + "recording.list", + method="GET", + params={ + "after": after, + "before": before, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + RecordingListResponse, + parse_obj_as( + type_=RecordingListResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawMeetingsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def recording_list( + self, + *, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[RecordingListResponse]: + """ + **Legacy:** Prefer [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) / + [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) for new integrations. + + Lists recordings in your home Roam, filtered by date range (after/before). + Organization clients without roam-wide meeting access + ([`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)) + receive `403`; use [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead. + This route remains registered for existing callers. It returns v0-style + identifiers and is not a v1 media-download path. + + The plural alias `/recordings.list` is also registered for existing callers; + use this singular form in new documentation and tooling. + + The ordering of results depends on the filter specified: + + - When no parameters are provided, the most recent recordings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + + - If `after` is specified, the results are sorted in forward chronological order. + + Either dates or datetimes may be specified. Dates are interpreted in UTC. + + **Access:** Organization only. Requires roam-wide meeting access. + + **Required scope:** `recordings:read` and `admin:meetings:read` (or a grandfathered roam-wide API key) + + Parameters + ---------- + after : typing.Optional[str] + The datetime to begin listing recordings (YYYY-MM-DD or RFC-3339). + Defaults to "no filter". + + before : typing.Optional[str] + The datetime until which to list recordings (YYYY-MM-DD or RFC-3339). + Defaults to "now". + + limit : typing.Optional[int] + The number of recordings to return per response. Default is 10. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[RecordingListResponse] + OK + """ + _response = await self._client_wrapper.httpx_client.request( + "recording.list", + method="GET", + params={ + "after": after, + "before": before, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + RecordingListResponse, + parse_obj_as( + type_=RecordingListResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/meetings/types/__init__.py b/src/roamhq/meetings/types/__init__.py new file mode 100644 index 0000000..8f93873 --- /dev/null +++ b/src/roamhq/meetings/types/__init__.py @@ -0,0 +1,40 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .recording_list_response import RecordingListResponse + from .recording_list_response_recordings_item import RecordingListResponseRecordingsItem +_dynamic_imports: typing.Dict[str, str] = { + "RecordingListResponse": ".recording_list_response", + "RecordingListResponseRecordingsItem": ".recording_list_response_recordings_item", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["RecordingListResponse", "RecordingListResponseRecordingsItem"] diff --git a/src/roamhq/meetings/types/recording_list_response.py b/src/roamhq/meetings/types/recording_list_response.py new file mode 100644 index 0000000..0f211d8 --- /dev/null +++ b/src/roamhq/meetings/types/recording_list_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .recording_list_response_recordings_item import RecordingListResponseRecordingsItem + + +class RecordingListResponse(UniversalBaseModel): + recordings: typing.Optional[typing.List[RecordingListResponseRecordingsItem]] = None + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Returned if there is a subsequent page of recordings."), + ] = None + """ + Returned if there is a subsequent page of recordings. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/meetings/types/recording_list_response_recordings_item.py b/src/roamhq/meetings/types/recording_list_response_recordings_item.py new file mode 100644 index 0000000..b5bbeac --- /dev/null +++ b/src/roamhq/meetings/types/recording_list_response_recordings_item.py @@ -0,0 +1,63 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class RecordingListResponseRecordingsItem(UniversalBaseModel): + recording_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="recordingId"), + pydantic.Field(alias="recordingId", description="A unique identifier for the recording"), + ] = None + """ + A unique identifier for the recording + """ + + location: typing.Optional[str] = pydantic.Field(default=None) + """ + Name of the Roam room where the recording took place + """ + + start_time: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="startTime"), + pydantic.Field(alias="startTime", description="Exact time when the recording began"), + ] = None + """ + Exact time when the recording began + """ + + end_time: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="endTime"), + pydantic.Field(alias="endTime", description="Exact time when the recording stopped"), + ] = None + """ + Exact time when the recording stopped + """ + + video_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="videoUrl"), + pydantic.Field(alias="videoUrl", description="URL where the video file may be downloaded"), + ] = None + """ + URL where the video file may be downloaded + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/reaction/__init__.py b/src/roamhq/reaction/__init__.py new file mode 100644 index 0000000..2bd4fc7 --- /dev/null +++ b/src/roamhq/reaction/__init__.py @@ -0,0 +1,39 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ListReactionResponse, ListReactionResponsePollVotesItem +_dynamic_imports: typing.Dict[str, str] = { + "ListReactionResponse": ".types", + "ListReactionResponsePollVotesItem": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListReactionResponse", "ListReactionResponsePollVotesItem"] diff --git a/src/roamhq/reaction/client.py b/src/roamhq/reaction/client.py new file mode 100644 index 0000000..4dc8d31 --- /dev/null +++ b/src/roamhq/reaction/client.py @@ -0,0 +1,441 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawReactionClient, RawReactionClient +from .types.list_reaction_response import ListReactionResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class ReactionClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawReactionClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawReactionClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawReactionClient + """ + return self._raw_client + + def add( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Add a reaction to a message in a chat. + + To react to a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Access:** The organization bot or personal-token **owner** must be a + member of the chat (`403` `not_in_chat` otherwise). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message to react to. + + timestamp : int + Timestamp of the message to react to (Unix microseconds). + + name : str + Name of the reaction to add (e.g. "thumbs_up", "heart", "100"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if reacting to a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.reaction.add( + chat_id="7be17589-4b9a-4524-bddb-ce60abea08e6", + timestamp=1755723832718034, + name="thumbs_up", + ) + """ + _response = self._raw_client.add( + chat_id=chat_id, + timestamp=timestamp, + name=name, + thread_timestamp=thread_timestamp, + request_options=request_options, + ) + return _response.data + + def remove( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Remove a reaction from a message in a chat. + + Only reactions added by the authenticated app can be removed. + + To remove a reaction from a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + name : str + Name of the reaction to remove (e.g. "thumbs_up", "heart"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if removing from a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.reaction.remove( + chat_id="7be17589-4b9a-4524-bddb-ce60abea08e6", + timestamp=1755723832718034, + name="thumbs_up", + ) + """ + _response = self._raw_client.remove( + chat_id=chat_id, + timestamp=timestamp, + name=name, + thread_timestamp=thread_timestamp, + request_options=request_options, + ) + return _response.data + + def list( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListReactionResponse: + """ + List reactions on a specific message, grouped by emoji (Slack-style + `{name, count, users}`). Poll votes are returned separately in `pollVotes` + rather than folded into `reactions`. + + `users` contains visible principal IDs only. Unknown or unauthorized actors + are omitted, and `count` is recomputed from the returned IDs. Hydrate them + with `user.list?ids`; these arrays do not carry inline type fields. + + To list reactions on a thread reply, provide the `threadTimestamp` of the + parent message and the `timestamp` of the specific reply. + + **Required scope:** `chat:history` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if listing reactions on a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListReactionResponse + Reactions retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.reaction.list( + chat_id="chatId", + timestamp=1, + ) + """ + _response = self._raw_client.list( + chat_id=chat_id, timestamp=timestamp, thread_timestamp=thread_timestamp, request_options=request_options + ) + return _response.data + + +class AsyncReactionClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawReactionClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawReactionClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawReactionClient + """ + return self._raw_client + + async def add( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Add a reaction to a message in a chat. + + To react to a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Access:** The organization bot or personal-token **owner** must be a + member of the chat (`403` `not_in_chat` otherwise). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message to react to. + + timestamp : int + Timestamp of the message to react to (Unix microseconds). + + name : str + Name of the reaction to add (e.g. "thumbs_up", "heart", "100"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if reacting to a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.reaction.add( + chat_id="7be17589-4b9a-4524-bddb-ce60abea08e6", + timestamp=1755723832718034, + name="thumbs_up", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.add( + chat_id=chat_id, + timestamp=timestamp, + name=name, + thread_timestamp=thread_timestamp, + request_options=request_options, + ) + return _response.data + + async def remove( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Remove a reaction from a message in a chat. + + Only reactions added by the authenticated app can be removed. + + To remove a reaction from a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + name : str + Name of the reaction to remove (e.g. "thumbs_up", "heart"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if removing from a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.reaction.remove( + chat_id="7be17589-4b9a-4524-bddb-ce60abea08e6", + timestamp=1755723832718034, + name="thumbs_up", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.remove( + chat_id=chat_id, + timestamp=timestamp, + name=name, + thread_timestamp=thread_timestamp, + request_options=request_options, + ) + return _response.data + + async def list( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListReactionResponse: + """ + List reactions on a specific message, grouped by emoji (Slack-style + `{name, count, users}`). Poll votes are returned separately in `pollVotes` + rather than folded into `reactions`. + + `users` contains visible principal IDs only. Unknown or unauthorized actors + are omitted, and `count` is recomputed from the returned IDs. Hydrate them + with `user.list?ids`; these arrays do not carry inline type fields. + + To list reactions on a thread reply, provide the `threadTimestamp` of the + parent message and the `timestamp` of the specific reply. + + **Required scope:** `chat:history` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if listing reactions on a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListReactionResponse + Reactions retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.reaction.list( + chat_id="chatId", + timestamp=1, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + chat_id=chat_id, timestamp=timestamp, thread_timestamp=thread_timestamp, request_options=request_options + ) + return _response.data diff --git a/src/roamhq/reaction/raw_client.py b/src/roamhq/reaction/raw_client.py new file mode 100644 index 0000000..e80e01e --- /dev/null +++ b/src/roamhq/reaction/raw_client.py @@ -0,0 +1,841 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.list_reaction_response import ListReactionResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawReactionClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def add( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[None]: + """ + Add a reaction to a message in a chat. + + To react to a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Access:** The organization bot or personal-token **owner** must be a + member of the chat (`403` `not_in_chat` otherwise). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message to react to. + + timestamp : int + Timestamp of the message to react to (Unix microseconds). + + name : str + Name of the reaction to add (e.g. "thumbs_up", "heart", "100"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if reacting to a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "reaction.add", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def remove( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[None]: + """ + Remove a reaction from a message in a chat. + + Only reactions added by the authenticated app can be removed. + + To remove a reaction from a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + name : str + Name of the reaction to remove (e.g. "thumbs_up", "heart"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if removing from a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "reaction.remove", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListReactionResponse]: + """ + List reactions on a specific message, grouped by emoji (Slack-style + `{name, count, users}`). Poll votes are returned separately in `pollVotes` + rather than folded into `reactions`. + + `users` contains visible principal IDs only. Unknown or unauthorized actors + are omitted, and `count` is recomputed from the returned IDs. Hydrate them + with `user.list?ids`; these arrays do not carry inline type fields. + + To list reactions on a thread reply, provide the `threadTimestamp` of the + parent message and the `timestamp` of the specific reply. + + **Required scope:** `chat:history` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if listing reactions on a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListReactionResponse] + Reactions retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "reaction.list", + method="GET", + params={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListReactionResponse, + parse_obj_as( + type_=ListReactionResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawReactionClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def add( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[None]: + """ + Add a reaction to a message in a chat. + + To react to a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Access:** The organization bot or personal-token **owner** must be a + member of the chat (`403` `not_in_chat` otherwise). + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message to react to. + + timestamp : int + Timestamp of the message to react to (Unix microseconds). + + name : str + Name of the reaction to add (e.g. "thumbs_up", "heart", "100"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if reacting to a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "reaction.add", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def remove( + self, + *, + chat_id: str, + timestamp: int, + name: str, + thread_timestamp: typing.Optional[int] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[None]: + """ + Remove a reaction from a message in a chat. + + Only reactions added by the authenticated app can be removed. + + To remove a reaction from a thread reply, provide the `threadTimestamp` of the parent message + and the `timestamp` of the specific reply. + + **Required scope:** `chat:send_message` or `chat:write` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + name : str + Name of the reaction to remove (e.g. "thumbs_up", "heart"). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if removing from a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "reaction.remove", + method="POST", + json={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + "name": name, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list( + self, + *, + chat_id: str, + timestamp: int, + thread_timestamp: typing.Optional[int] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListReactionResponse]: + """ + List reactions on a specific message, grouped by emoji (Slack-style + `{name, count, users}`). Poll votes are returned separately in `pollVotes` + rather than folded into `reactions`. + + `users` contains visible principal IDs only. Unknown or unauthorized actors + are omitted, and `count` is recomputed from the returned IDs. Hydrate them + with `user.list?ids`; these arrays do not carry inline type fields. + + To list reactions on a thread reply, provide the `threadTimestamp` of the + parent message and the `timestamp` of the specific reply. + + **Required scope:** `chat:history` + + Parameters + ---------- + chat_id : str + The chat containing the message. + + timestamp : int + Timestamp of the message (Unix microseconds). + + thread_timestamp : typing.Optional[int] + Timestamp of the parent thread message (Unix microseconds), if listing reactions on a thread reply. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListReactionResponse] + Reactions retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "reaction.list", + method="GET", + params={ + "chatId": chat_id, + "timestamp": timestamp, + "threadTimestamp": thread_timestamp, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListReactionResponse, + parse_obj_as( + type_=ListReactionResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/reaction/types/__init__.py b/src/roamhq/reaction/types/__init__.py new file mode 100644 index 0000000..91b7d7b --- /dev/null +++ b/src/roamhq/reaction/types/__init__.py @@ -0,0 +1,40 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .list_reaction_response import ListReactionResponse + from .list_reaction_response_poll_votes_item import ListReactionResponsePollVotesItem +_dynamic_imports: typing.Dict[str, str] = { + "ListReactionResponse": ".list_reaction_response", + "ListReactionResponsePollVotesItem": ".list_reaction_response_poll_votes_item", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListReactionResponse", "ListReactionResponsePollVotesItem"] diff --git a/src/roamhq/reaction/types/list_reaction_response.py b/src/roamhq/reaction/types/list_reaction_response.py new file mode 100644 index 0000000..c073435 --- /dev/null +++ b/src/roamhq/reaction/types/list_reaction_response.py @@ -0,0 +1,67 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.reaction import Reaction +from .list_reaction_response_poll_votes_item import ListReactionResponsePollVotesItem + + +class ListReactionResponse(UniversalBaseModel): + ok: typing.Optional[bool] = None + chat_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="The chat containing the message."), + ] + """ + The chat containing the message. + """ + + timestamp: int = pydantic.Field() + """ + Timestamp of the message (Unix microseconds). + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", description="Parent thread timestamp when the message is a thread reply." + ), + ] = None + """ + Parent thread timestamp when the message is a thread reply. + """ + + reactions: typing.List[Reaction] = pydantic.Field() + """ + Emoji reactions, one entry per distinct reaction name. + """ + + poll_votes: typing_extensions.Annotated[ + typing.List[ListReactionResponsePollVotesItem], + FieldMetadata(alias="pollVotes"), + pydantic.Field( + alias="pollVotes", + description="Poll option votes when the message is a poll. Not included among\n`reactions`.", + ), + ] + """ + Poll option votes when the message is a poll. Not included among + `reactions`. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/reaction/types/list_reaction_response_poll_votes_item.py b/src/roamhq/reaction/types/list_reaction_response_poll_votes_item.py new file mode 100644 index 0000000..ec05f1d --- /dev/null +++ b/src/roamhq/reaction/types/list_reaction_response_poll_votes_item.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class ListReactionResponsePollVotesItem(UniversalBaseModel): + option_id: typing_extensions.Annotated[ + str, FieldMetadata(alias="optionId"), pydantic.Field(alias="optionId", description="Poll option identifier.") + ] + """ + Poll option identifier. + """ + + text: typing.Optional[str] = pydantic.Field(default=None) + """ + Option display text (omitted if the option no longer resolves). + """ + + count: int + users: typing.List[str] + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/reference.md b/src/roamhq/reference.md new file mode 100644 index 0000000..95cc1d2 --- /dev/null +++ b/src/roamhq/reference.md @@ -0,0 +1,7263 @@ +# Reference +## Chat +
client.chat.list(...) -> ListChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List accessible chats — DMs, multi-DMs, group chats, all-hands "team +Roam" groups, and meeting chats. + +**Personal access tokens** are backed by the user's inbox: chats are +ordered by most recent activity and include `lastMessageTime`, +`isUnread`, `preview`, `isMuted`, and `isPinned`. Bot threads (where +the user has unread replies) are returned as separate rows keyed by +`threadTimestamp`. + +**Organization tokens** receive the chats the bot has access to, +ordered by chat creation time. Inbox-derived fields +(`lastMessageTime`, `isUnread`, `preview`, `isMuted`, `isPinned`) are +not populated, since bot addresses do not accumulate inbox state for +normal messages — those are delivered via webhooks. + +Timestamps are returned in the caller's timezone (see +[Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + +**Required scope:** `chat:read` + +Pass `expand=addresses` to include an address sidecar for chat participants +and preview senders. See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — Number of chats to return per response. Default 10, max 50. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**expand:** `typing.Optional[str]` + +Comma-separated fields to expand. Supported: `addresses` — include an +`addresses` map resolving chat participants and preview sender IDs. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.post(...) -> PostChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Send a message to a chat. Messages can be plain markdown text, rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts, or polls. + +**Destination (ONE of the following is required):** +- `chatId` - Post to an existing chat by its ID +- `groupId` - Post to a group chat +- `userIds` - Post to a DM or Multi-DM with the specified users + +You must specify exactly one destination. Specifying multiple destinations (e.g., both `chatId` and `groupId`) will return a 400 error. + +Mentions use Slack's token syntax with Slack's semantics: `<@ID>` mentions a principal (a user or bot, e.g. `<@7861a4c6-765a-495d-898d-fae3d8fbba2d>` — resolvable via [`user.info`](https://developer.ro.am/docs/api/user-info)), `` mentions a group or channel, notifying its members (resolvable via [`group.info`](https://developer.ro.am/docs/api/group-info)), and `` notifies everyone in the chat. +When rendered in the client, the tag will automatically be replaced with the human-readable display name (or "everyone" for ``). +On write, either token form is accepted for any mentionable ID; the legacy `<@all>` broadcast alias is accepted; and a Slack-style `|label` suffix (e.g. `<@7861a4c6-…|Rob>`, ``) is accepted and ignored — the mentioned entity's live display name is always used. Write-side acceptance is identical on every [API version](https://developer.ro.am/docs/guides/api-versioning). Messages read back always carry bare canonical tokens, and `` for the broadcast — on API versions from `2026-08-07`; clients pinned to older versions read the older grammar (`<@ID>` for every mention, `<@all>`). Slack forms Roam does not implement are reserved and stay literal text: `<#ID>` channel links, ``, and ``. + +**Custom sender (optional):** see the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). +- `sender.name` / `sender.imageUrl` are per-message display overrides, stored on the message itself. +- `sender.id` authors the message as a configured bot persona (Roam Administration > Developer > edit your app > Add Bot Persona). Ids that don't match a configured persona are accepted and ignored — the message is authored by the app's root identity. Sending never creates or renames personas. +- **Personal access tokens**: Reject the `sender` field with 400. PATs always post as their personal bot. + +**Access:** Organization tokens can post to chats the bot is a member of, +and to **public groups** in the workspace without joining. Personal tokens +can post only where the owner is a member (`403` `not_in_chat` for an +unjoined public group). Full membership matrix: +[Chat](https://developer.ro.am/docs/guides/chat). + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.post( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + text="Hello from the **API**", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `typing.Optional[str]` — Post to an existing chat by ID (mutually exclusive with groupId/userIds) + +
+
+ +
+
+ +**group_id:** `typing.Optional[str]` — Post to a group channel (mutually exclusive with chatId/userIds) + +
+
+ +
+
+ +**user_ids:** `typing.Optional[typing.List[str]]` — Post to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` + +Reply to a specific thread by providing the thread's timestamp. +If the timestamp doesn't correspond to an existing message, a 400 error is returned. +Mutually exclusive with `threadKey`. + +
+
+ +
+
+ +**thread_key:** `typing.Optional[str]` + +A stable external identifier used to group related messages into a thread. +On the first use of a given `threadKey`, a new message is posted and the resulting +thread timestamp is stored. Subsequent messages with the same `threadKey` are +automatically threaded under the original message. + +This is useful for external integrations (e.g. PagerDuty, Grafana, Sentry) that +want to thread related messages using their own identifiers (such as `dedup_key`, +`fingerprint`, or `group_id`) without tracking Roam's internal thread timestamps. + +Mutually exclusive with `threadTimestamp`. When `threadKey` is provided, the +response is always synchronous (equivalent to `sync: true`). + +
+
+ +
+
+ +**reply_timestamp:** `typing.Optional[int]` + +Reply directly to a specific message by its timestamp. Unlike +`threadTimestamp` (which threads a reply under a parent message in a +group), `replyTimestamp` is a direct reply used in DMs — which have no +threads — and within an existing channel thread. Text messages only: +not supported together with `blocks` or `poll`. + +
+
+ +
+
+ +**text:** `typing.Optional[str]` — Message text in GitHub-flavored markdown + +
+
+ +
+
+ +**markdown:** `typing.Optional[bool]` — Text is markdown by default. If set to false, markdown interpretation will be disabled. + +
+
+ +
+
+ +**items:** `typing.Optional[typing.List[str]]` — Array of Item IDs to attach to this message. + +
+
+ +
+
+ +**asset_ids:** `typing.Optional[typing.List[str]]` + +Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) +to attach to this message. Each asset must be owned by your app +and fully uploaded (processed and ready). Combines with +`text`/`items`; not with `blocks` or `poll`. + +
+
+ +
+
+ +**blocks:** `typing.Optional[typing.List[PostChatRequestBlocksItem]]` + +Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. +Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + +
+
+ +
+
+ +**color:** `typing.Optional[str]` + +Colored vertical strip on the side of the message. Only used with `blocks`. +Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + +
+
+ +
+
+ +**poll:** `typing.Optional[PostChatRequestPoll]` — Create a poll message. Mutually exclusive with `text`, `items`, and `blocks`. + +
+
+ +
+
+ +**sender:** `typing.Optional[Sender]` + +
+
+ +
+
+ +**sync:** `typing.Optional[bool]` — If set, the post will be performed synchronously and its timestamp returned. Incompatible with `sendAt`. + +
+
+ +
+
+ +**send_at:** `typing.Optional[datetime.datetime]` + +Schedule the message for later delivery (RFC 3339). Requirements: +- Must be in the **future** and within **30 days** +- Must fall on a **15-minute UTC boundary** (`:00`, `:15`, `:30`, or `:45`; seconds and sub-seconds zero) +- Incompatible with `sync`, `poll`, `threadKey`, and `replyTimestamp` + +When `sendAt` is set, the response is `{chatId, scheduledMessageId, sendAt}` +instead of an immediate message `timestamp`. + +Scheduled messages can be listed via +[`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list) and canceled via +[`/chat.scheduled.cancel`](https://developer.ro.am/docs/api/chat-scheduled-cancel) until they send. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.post_ephemeral(...) -> PostEphemeralChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Post an **ephemeral message** — visible to a single member of a chat, with an +"Only you can see this" header — without posting anything the other members can +see. This is the standard way for a bot to respond privately in a shared +channel (the Roam equivalent of Slack's `chat.postEphemeral`). + +The target `userId` must be a member of the chat (for channels: a member of the +backing group), otherwise the request fails with `user_not_in_chat`. + +`text` is always rendered as GitHub-flavored markdown. Mention markup +(`<@USER_ID>`) is **not** supported in ephemeral messages. Block Kit `blocks` +are not currently supported. + +**Delivery semantics — read before using:** +- **Desktop and web only.** Mobile clients do not display ephemeral messages, + and no mobile push notification is sent. A recipient who only uses Roam on + mobile will never see the message. +- **Best-effort, at-most-once.** The message is delivered in real time to the + recipient's connected clients, and to recently-active offline clients when + they reconnect. A recipient who has been offline for several days (or has + never signed in on that device) silently misses it. There are no retries + and no delivery receipt. +- **Transient.** The message is never stored server-side. It disappears when + the recipient restarts their app, and it never appears in + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) or [`/chat.search`](https://developer.ro.am/docs/api/chat-search). +- **Not addressable.** It cannot be edited or deleted: + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) and [`/chat.delete`](https://developer.ro.am/docs/api/chat-delete) + against its `(chatId, timestamp)` return `message_not_found`. +- **No webhooks.** Posting an ephemeral message never triggers a + [`chat.message`](https://developer.ro.am/docs/webhooks/chat-message) event, so it cannot leak to + org-wide webhook consumers. + +Do not use ephemeral messages for anything the recipient must durably receive — +use a DM ([`/chat.post`](https://developer.ro.am/docs/api/chat-post) with `userIds`) for that. + +**Custom sender (optional):** same semantics as [`/chat.post`](https://developer.ro.am/docs/api/chat-post) — +`sender.name` / `sender.imageUrl` apply a per-message display override, and +`sender.id` authors the message as a configured bot persona (unknown ids +are accepted and ignored). Personal access tokens reject the `sender` +field. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + +**Required scope:** `chat:send_message` or `chat:write` + +**Access:** Organization and Personal. The organization bot or +personal-token **owner** must be a member of the chat (`403` `not_in_chat` +otherwise) — unlike [`/chat.post`](https://developer.ro.am/docs/api/chat-post), there is no +public-group carveout. Personal tokens send as the user's personal bot +and reject the `sender` field. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.post_ephemeral( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + user_id="7861a4c6-765a-495d-898d-fae3d8fbba2d", + text="Only *you* can see this: your deploy token expires in 3 days.", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `str` — The chat to post into. Use [`/chat.list`](https://developer.ro.am/docs/api/chat-list) or a `chat.message` webhook payload to obtain chat IDs. + +
+
+ +
+
+ +**user_id:** `str` — The user who should see the message. Must be a member of the chat. + +
+
+ +
+
+ +**text:** `str` + +Message text in GitHub-flavored markdown (always rendered as +markdown; there is no plain-text mode). Maximum 8,000 bytes. +Mention markup is not supported. + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` + +Show the ephemeral message inside an existing thread instead of the +main channel view. Channels only — returns 400 in DMs and Multi-DMs. +The value is not validated against an existing thread: pass a real +thread's timestamp, or the message is keyed under a thread view the +recipient can never open and is effectively never seen. + +
+
+ +
+
+ +**sender:** `typing.Optional[Sender]` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.list_scheduled(...) -> ListScheduledChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Lists pending messages scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s `sendAt` +that have not been sent yet. Results are ordered ascending by `sendAt` (soonest +first). Sent and canceled messages are not returned. + +Only messages scheduled by the calling credential's bot identity are listed: +organization tokens of the same app share the app's bot identity (and therefore +see each other's scheduled messages), while personal access tokens have a +per-person bot identity and see only their own. + +**Access:** Organization and Personal. + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.list_scheduled() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `typing.Optional[str]` — Only return messages scheduled for this chat. + +
+
+ +
+
+ +**after:** `typing.Optional[datetime.datetime]` + +Only return messages scheduled to send after this datetime +(YYYY-MM-DD or RFC-3339). Exclusive. + +
+
+ +
+
+ +**before:** `typing.Optional[datetime.datetime]` + +Only return messages scheduled to send before this datetime +(YYYY-MM-DD or RFC-3339). Exclusive. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — The number of scheduled messages to return per response. Default is 10. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.cancel_scheduled(...) -> CancelScheduledChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Cancels a pending message scheduled via [`/chat.post`](https://developer.ro.am/docs/api/chat-post)'s +`sendAt`, so it will never be delivered. Pending scheduled messages can be +discovered with [`/chat.scheduled.list`](https://developer.ro.am/docs/api/chat-scheduled-list). + +Only the credential's bot identity that scheduled the message may cancel it. A +`scheduledMessageId` scheduled by a different identity — or one that never +existed — returns `scheduled_message_not_found`; the endpoint does not reveal +whether such an id exists. Canceling a message that has already been sent +returns `scheduled_message_already_sent`. + +Cancellation is best-effort once the scheduled send time arrives: delivery of a +due message begins in the seconds after its `sendAt` boundary, and a cancel +issued inside that window may return success while the message is still +delivered. Cancel ahead of the scheduled time to be safe. + +**Access:** Organization and Personal. + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.cancel_scheduled( + scheduled_message_id="0197f9f0-5cc1-7d07-8a12-9e65a8a0c1b9", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**scheduled_message_id:** `str` — The id returned by `/chat.post` when the message was scheduled. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.start_stream(...) -> StartStreamChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Open a streaming message and post its first content. Streaming lets a bot +deliver a message incrementally — recipients see the text fill in live (with +a "typing…" indicator) instead of waiting for the full response. This is +useful for AI agents that produce text token-by-token. + +A stream has three steps, each its own request: + +1. **[`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)** — open the stream and pick the destination. Returns a `streamId`. +2. **[`/chat.appendStream`](https://developer.ro.am/docs/api/chat-append-stream)** — append chunks of text (call as many times as needed). +3. **[`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream)** — finalize the stream into a single persisted message. + +Pass the `streamId` returned here to every subsequent `appendStream` and +`stopStream`. The sender, destination, and thread are fixed for the lifetime +of the stream. + +**Custom sender (optional):** same semantics as +[`/chat.post`](https://developer.ro.am/docs/api/chat-post) — `sender.name` / `sender.imageUrl` +apply a per-message display override to the finalized message, and +`sender.id` authors the stream as a configured bot persona (unknown ids +are accepted and ignored). The typing indicator shown while streaming uses +the override name when given, otherwise the persona's or app's configured +name. See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + +**Access:** Organization and Personal. Organization tokens follow the +same public-group carveout as [`/chat.post`](https://developer.ro.am/docs/api/chat-post): the +bot may stream into a public group in its roam without joining. +Personal tokens can stream only where the owner is a member +(`403` `not_in_chat` for an unjoined public group) and reject the +`sender` field. + +**Required scope:** `chat:send_message` or `chat:write` + +## Destination + +Provide exactly one of `chatId`, `groupId`, or `userIds`. If `text` is empty, +the destination is recorded but message creation is deferred until the first +non-empty `appendStream` or the `stopStream` call. + +## Thinking streams + +Set `kind` to `thinking` to finalize the message as a thought-bubble; clients +show a "thinking…" indicator instead of "typing…". The default `kind` is `text`. + +## Limits + +- Up to **10 concurrent streams per API client**. +- Only **one active stream per chat** at a time. +- Accumulated text may not exceed the regular message size limit. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.start_stream( + group_id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + text="Let me look into that...", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `typing.Optional[str]` — Stream into an existing chat by ID (mutually exclusive with groupId/userIds). + +
+
+ +
+
+ +**group_id:** `typing.Optional[str]` — Stream into a group chat (mutually exclusive with chatId/userIds). + +
+
+ +
+
+ +**user_ids:** `typing.Optional[typing.List[str]]` — Stream into a DM or Multi-DM with these users (mutually exclusive with chatId/groupId). + +
+
+ +
+
+ +**kind:** `typing.Optional[StartStreamChatRequestKind]` — Stream kind. `thinking` finalizes as a thought-bubble message. + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — Optional thread to reply within. + +
+
+ +
+
+ +**text:** `typing.Optional[str]` — Optional initial text. May be empty to defer destination resolution until the first append/stop. + +
+
+ +
+
+ +**sender:** `typing.Optional[Sender]` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.append_stream(...) -> AppendStreamChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Append a chunk of text to an open stream (see +[`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)). Each chunk is broadcast +to recipients as a delta, so the message appears to fill in live. Call as +many times as needed before [`/chat.stopStream`](https://developer.ro.am/docs/api/chat-stop-stream). + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.append_stream( + stream_id="018f5c8e-7d2a-7c4e-8f9a-1a2b3c4d5e6f", + text=" The answer is 42.", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**stream_id:** `str` — The stream ID returned by chat.startStream. + +
+
+ +
+
+ +**text:** `str` — Text chunk to append. Required and non-empty. + +
+
+ +
+
+ +**snapshot:** `typing.Optional[bool]` + +If `true`, **replace** the accumulated text with `text` (and broadcast it +as a full snapshot) instead of appending. Useful when the client holds the +canonical current state — for example after rewriting prior output. The +message size limit is applied to the new `text` alone. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.stop_stream(...) -> StopStreamChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Finalize an open stream (see [`/chat.startStream`](https://developer.ro.am/docs/api/chat-start-stream)) +into a single persisted chat message and return its timestamp. Optionally +include trailing `text` to append before finalizing. + +If the app never calls `stopStream` but has already streamed some text, the +server finalizes the buffered text into a message automatically. + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.stop_stream( + stream_id="018f5c8e-7d2a-7c4e-8f9a-1a2b3c4d5e6f", + text=" Hope that helps!", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**stream_id:** `str` — The stream ID returned by chat.startStream. + +
+
+ +
+
+ +**text:** `typing.Optional[str]` — Optional trailing text appended before the message is finalized. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.update(...) -> UpdateChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Edit a previously posted bot message. The updated message can contain plain markdown text or rich [Block Kit](https://developer.ro.am/docs/guides/block-kit) layouts. + +The bot must own the message being updated (matched by address ID). Personal access tokens always send as their bot persona and may only edit messages that personal bot posted. + +**Access:** Organization and Personal. + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.update( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + timestamp=1765602474760032, + text="Updated message content with **bold text**", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `str` — ID of the chat containing the message. + +
+
+ +
+
+ +**timestamp:** `int` — Timestamp of the message to update. + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — Thread timestamp, if the message is in a thread. + +
+
+ +
+
+ +**text:** `typing.Optional[str]` + +Updated markdown-formatted text content. Required unless `blocks` is provided. +Cannot be combined with `blocks`. + +
+
+ +
+
+ +**markdown:** `typing.Optional[bool]` — Text is markdown by default. If this is set to false, markdown interpretation will be disabled. + +
+
+ +
+
+ +**items:** `typing.Optional[typing.List[str]]` — Array of Item IDs to attach to this message. Cannot be combined with `blocks`. + +
+
+ +
+
+ +**asset_ids:** `typing.Optional[typing.List[str]]` + +Array of asset IDs from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) +to attach to this message. Each asset must be owned by your app +and fully uploaded (processed and ready). Cannot be combined with `blocks`. + +
+
+ +
+
+ +**blocks:** `typing.Optional[typing.List[UpdateChatRequestBlocksItem]]` + +Array of [Block Kit](https://developer.ro.am/docs/guides/block-kit) block objects for rich message formatting. +Cannot be combined with `text` or `items`. Maximum 10 blocks, 8,000 bytes total payload. + +
+
+ +
+
+ +**color:** `typing.Optional[str]` + +Colored vertical strip on the side of the message. Only used with `blocks`. +Named values: `good` (green), `warning` (yellow), `danger` (red), or a hex color like `#5B3FD9`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.delete(...) -> DeleteChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Delete a previously posted bot message. The bot must own the message being deleted (matched by address ID). Personal access tokens always send as their bot persona and may only delete messages that personal bot posted. + +Deleting an already-deleted message is idempotent and returns success. + +**Access:** Organization and Personal. + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.delete( + chat_id="757dfe66-37b4-4772-baa5-8c86ec68c176", + timestamp=1765602474760032, +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `str` — ID of the chat containing the message. + +
+
+ +
+
+ +**timestamp:** `int` — Timestamp of the message to delete. + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — Thread timestamp, if the message is in a thread. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.typing(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Notify other chat participants that you are working on a response. +If they have the chat open, they will see "(Bot name) is typing...". + +The indicator lasts **6 seconds**. Re-send every **5 seconds** to keep +it visible while you work. Longer gaps will let it expire between pings. + +**Destination options (mutually exclusive):** +- `chatId` - Send to an existing chat by its ID +- `groupId` - Send to a group channel +- `userIds` - Send to a DM or Multi-DM with the specified users + +**Custom sender (optional):** pass `sender.id` to show the indicator as a +[configured bot persona](https://developer.ro.am/docs/guides/sender-profiles) — the persona's +configured name and avatar are used. Only `id` is accepted; `name` and +`imageUrl` are rejected on this endpoint. Selection is lookup-only: an id +that doesn't match a configured persona is accepted and ignored, and the +indicator shows the app's own identity (same for an omitted, empty, or `_` +id). Personal access tokens reject `sender` entirely. + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.typing( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `typing.Optional[str]` — Send to an existing chat by ID (mutually exclusive with groupId/userIds) + +
+
+ +
+
+ +**group_id:** `typing.Optional[str]` — Send to a group channel (mutually exclusive with chatId/userIds) + +
+
+ +
+
+ +**user_ids:** `typing.Optional[typing.List[str]]` — Send to a DM or Multi-DM with these users (mutually exclusive with chatId/groupId) + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — Timestamp of the message being replied to. + +
+
+ +
+
+ +**sender:** `typing.Optional[TypingChatRequestSender]` + +Optional configured bot persona to show the indicator as. Only +`id` is accepted — `name` and `imageUrl` are rejected on this +endpoint. Personal access tokens reject this field entirely. +See the [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.history(...) -> HistoryChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List messages in a chat, filtered by date range (after/before). + +Messages with `contentType` of `text`, `voice`, or `poll` are returned. System messages and other content types are excluded. + +**Specify ONE of the following:** +- `chatId` - Fetch from an existing chat by its ID +- `groupId` - Fetch from a group chat +- `userIds` - Fetch from a DM or Multi-DM with the specified users + +You must specify exactly one destination. Specifying multiple (e.g., both `chatId` and `groupId`) will return a 400 error. + +The ordering of results depends on the filter specified: + +- When no parameters are provided, the most recent messages are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + +- If `after` is specified, the results are sorted in forward chronological order. + +Either dates or datetimes may be specified. Date-only inputs (`YYYY-MM-DD`) +are interpreted in the caller's timezone (see +[Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + +**Access:** Organization tokens need to be a **member** of the chat +(`403` `not_in_chat` otherwise). Personal tokens can read any chat the +owner can, including public groups in their roam they have not joined. +Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + +**Required scope:** `chat:history` + +Every returned sender includes `userId` plus `userType`. The ID resolves +through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.history() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `typing.Optional[str]` — The chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + +
+
+ +
+
+ +**group_id:** `typing.Optional[str]` — Group chat ID to fetch messages from. Either chatId, groupId, or userIds must be specified. + +
+
+ +
+
+ +**user_ids:** `typing.Optional[typing.Union[str, typing.Sequence[str]]]` — User IDs to fetch DM/Multi-DM messages with. Either chatId, groupId, or userIds must be specified. + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[float]` — Read replies of the message with this timestamp. Specified in microseconds. + +
+
+ +
+
+ +**after:** `typing.Optional[str]` + +The datetime to begin listing messages (YYYY-MM-DD or RFC-3339). +Date-only values are interpreted in the caller's timezone. +Sub-millisecond precision on datetimes is truncated. Defaults to +"no filter". + +
+
+ +
+
+ +**before:** `typing.Optional[str]` + +The datetime until which to list messages (YYYY-MM-DD or RFC-3339). +Date-only values are interpreted in the caller's timezone. +Sub-millisecond precision on datetimes is truncated. Defaults to +"now". + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — Number of messages to return (default 10, max 200). + +
+
+ +
+
+ +**expand:** `typing.Optional[str]` + +Comma-separated fields to expand. Supported: `addresses` — include an +`addresses` map resolving the sender (`userId`) and mentioned IDs on +each message to their display info. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.search(...) -> SearchChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Full-text search over the caller's accessible messages. Returns +full-fidelity messages — text, items, voice, polls, blocks, and +mentions — hydrated through the same pipeline as +[`/chat.history`](https://developer.ro.am/docs/api/chat-history). + +All fields are optional. With no parameters, the most recent messages +across all chat types (DMs, multi-DMs, group chats) are returned in +reverse chronological order. + +**Sort:** When omitted and `query` is empty, results are sorted +chronologically (newest first), since relevance scoring is meaningless +without search terms. Pass `sort: recent` to force chronological order +even with a text query. + +**Date filters:** `before` and `after` accept `YYYY-MM-DD`. Dates are +interpreted in the caller's timezone (see +[Timezone handling](https://developer.ro.am/docs/guides/migration-v0-to-v1#timezone-handling)). + +**Access:** Organization and Personal. + +- **Personal tokens** search chats the owner can read, including public + groups in their roam they have not joined. +- **Organization tokens** search chats the bot is a **member** of, + plus unjoined **public** groups in the bot's roam (Slack + `search:read.public`). Private groups the bot is not in are excluded. + [`/chat.history`](https://developer.ro.am/docs/api/chat-history) stays membership-only. + +Full membership matrix: [Chat](https://developer.ro.am/docs/guides/chat). + +**Required scope:** `chat:history` + +Every returned sender includes `userId` plus `userType`. The ID resolves +through [`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.search( + after="2026-04-13", + limit=20, +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**query:** `typing.Optional[str]` — Free-text search query. Empty matches all messages. + +
+
+ +
+
+ +**in:** `typing.Optional[typing.List[str]]` — Group names to search within. + +
+
+ +
+
+ +**from:** `typing.Optional[typing.List[str]]` — Filter to messages sent by these email addresses. + +
+
+ +
+
+ +**with:** `typing.Optional[typing.List[str]]` — Filter to chats including these email addresses. + +
+
+ +
+
+ +**before:** `typing.Optional[str]` — Only include messages before this date (`YYYY-MM-DD`, caller's timezone). + +
+
+ +
+
+ +**after:** `typing.Optional[str]` — Only include messages on or after this date (`YYYY-MM-DD`, caller's timezone). + +
+
+ +
+
+ +**has:** `typing.Optional[typing.List[SearchChatRequestHasItem]]` — Restrict to messages that contain a mention or an item. + +
+
+ +
+
+ +**chat_types:** `typing.Optional[typing.List[SearchChatRequestChatTypesItem]]` + +Restrict to specific chat types. Defaults to all types +(channels, all-hands "team Roam" groups, and DMs). + +
+
+ +
+
+ +**exclude_chat_ids:** `typing.Optional[typing.List[str]]` — Chat IDs to exclude from results. + +
+
+ +
+
+ +**exclude_user_ids:** `typing.Optional[typing.List[str]]` — Sender user IDs to exclude from results. + +
+
+ +
+
+ +**sort:** `typing.Optional[SearchChatRequestSort]` + +`relevant` (default) ranks by relevance to `query`; `recent` +sorts newest first. With an empty `query`, results are +sorted chronologically regardless. + +
+
+ +
+
+ +**expand:** `typing.Optional[str]` + +Comma-separated fields to expand. Supported: `addresses` — +include an `addresses` map resolving the sender (`userId`) and +mentioned IDs on each message to their display info. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — Number of messages per page (max 200). + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.resolve_link(...) -> ResolveLinkChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Parse a Roam chat deep link (e.g. `https://ro.am/r/#/d/...`) and return the +referenced message. + +When the caller has access to the referenced chat, the full message is +returned and `readable` is `true`. The `message` object is the same +shape as a `chat.history`/`chat.search` message — same fields, same +mention rendering. When the caller lacks access, the response still +includes the message key (`chatId`, `timestamp`, and `threadTimestamp` +if applicable) with `readable: false` and no message content — suitable +for rendering a reference without leaking content. + +Use [`/chat.link.create`](https://developer.ro.am/docs/api/chat-link-create) for the reverse +operation — minting a shareable Roam link from a message the caller can +already read. + +**Access:** Organization and Personal. + +**Required scope:** `chat:history` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.resolve_link( + link="https://ro.am/r/#/d/abc123xyz/c/757dfe66-37b4-4772-baa5-8c86ec68c176?ts=1765602474760032", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**link:** `str` — A Roam chat deep link URL that contains a message reference. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.create_link(...) -> CreateLinkChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a shareable Roam link to a specific chat message. Opening the link +in Roam navigates to that message in its chat. + +Identify the chat with exactly one of `chatId`, `groupId`, or `userIds`, +and the message by its `timestamp` (Unix microseconds), as returned by +[`/chat.history`](https://developer.ro.am/docs/api/chat-history), [`/chat.post`](https://developer.ro.am/docs/api/chat-post), +or webhook message events. For a thread reply, also pass the thread root's +timestamp as `threadTimestamp` — without it the reply will not be found. + +The message must exist and be readable by the caller; otherwise no link is +returned (`404` if the message does not exist, `403` if the caller is not a +member of the chat). The link itself does not grant access: recipients can +only open it if they are members of the chat. + +Use [`/chat.link.resolve`](https://developer.ro.am/docs/api/chat-link-resolve) for the reverse +operation — turning a Roam chat link back into the referenced message. + +**Access:** Organization and Personal. In Personal mode, only chats the +authenticated user can access are allowed. + +**Required scope:** `chat:history` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.create_link( + chat_id="295155ae-7df5-4ed5-9ebc-89a170559c81", + timestamp=1765602474760032, +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**timestamp:** `int` — The message's timestamp in Unix microseconds. + +
+
+ +
+
+ +**chat_id:** `typing.Optional[str]` — ID of the chat containing the message. Exactly one of `chatId`, `groupId`, or `userIds` is required. + +
+
+ +
+
+ +**group_id:** `typing.Optional[str]` — ID of a group whose channel chat contains the message. + +
+
+ +
+
+ +**user_ids:** `typing.Optional[typing.List[str]]` — User ID(s) identifying the DM or group DM containing the message. + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — The thread root's timestamp in Unix microseconds. Required when the message is a thread reply. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.chat.unfurl(...) -> UnfurlChatResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Attach app-provided preview cards to links in an existing text message. +Every map key must be an exact URL currently present in the message and +must match one of the app's registered unfurl domains. Validation is +atomic: if any entry is invalid, no previews are changed. + +App previews replace Roam-generated previews for the same exact URL while +preserving unrelated previews. The server does not fetch any URL supplied +in this request. + +**Access:** Organization only (API Key or OAuth). Register unfurl domains on +the API client first — see [Unfurling links](https://developer.ro.am/docs/guides/unfurling-links). +Personal Access Tokens cannot register domains or call this endpoint. + +**Required scope:** `links:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient, UnfurlContent, UnfurlContentImage +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.chat.unfurl( + chat_id="8f3b9c2e-1a4d-4e7b-9c0a-2b6d1f5e3a7c", + message_timestamp=1748906400000000, + unfurls={ + "https://status.example.com/incidents/123": UnfurlContent( + title="Incident 123", + description="Investigating elevated errors", + site_name="PagerDuty", + favicon="https://status.example.com/favicon.png", + image=UnfurlContentImage( + url="https://status.example.com/incident.png", + type="image/png", + width=1200, + height=630, + alt="Incident status", + ), + ) + }, +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `str` + +
+
+ +
+
+ +**message_timestamp:** `int` — Timestamp of a top-level or threaded message in Unix microseconds. + +
+
+ +
+
+ +**unfurls:** `typing.Dict[str, UnfurlContent]` — Preview content keyed by the exact URL from the message. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Reaction +
client.reaction.add(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Add a reaction to a message in a chat. + +To react to a thread reply, provide the `threadTimestamp` of the parent message +and the `timestamp` of the specific reply. + +**Access:** The organization bot or personal-token **owner** must be a +member of the chat (`403` `not_in_chat` otherwise). + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.reaction.add( + chat_id="7be17589-4b9a-4524-bddb-ce60abea08e6", + timestamp=1755723832718034, + name="thumbs_up", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `str` — The chat containing the message to react to. + +
+
+ +
+
+ +**timestamp:** `int` — Timestamp of the message to react to (Unix microseconds). + +
+
+ +
+
+ +**name:** `str` — Name of the reaction to add (e.g. "thumbs_up", "heart", "100"). + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — Timestamp of the parent thread message (Unix microseconds), if reacting to a thread reply. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.reaction.remove(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Remove a reaction from a message in a chat. + +Only reactions added by the authenticated app can be removed. + +To remove a reaction from a thread reply, provide the `threadTimestamp` of the parent message +and the `timestamp` of the specific reply. + +**Required scope:** `chat:send_message` or `chat:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.reaction.remove( + chat_id="7be17589-4b9a-4524-bddb-ce60abea08e6", + timestamp=1755723832718034, + name="thumbs_up", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `str` — The chat containing the message. + +
+
+ +
+
+ +**timestamp:** `int` — Timestamp of the message (Unix microseconds). + +
+
+ +
+
+ +**name:** `str` — Name of the reaction to remove (e.g. "thumbs_up", "heart"). + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — Timestamp of the parent thread message (Unix microseconds), if removing from a thread reply. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.reaction.list(...) -> ListReactionResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List reactions on a specific message, grouped by emoji (Slack-style +`{name, count, users}`). Poll votes are returned separately in `pollVotes` +rather than folded into `reactions`. + +`users` contains visible principal IDs only. Unknown or unauthorized actors +are omitted, and `count` is recomputed from the returned IDs. Hydrate them +with `user.list?ids`; these arrays do not carry inline type fields. + +To list reactions on a thread reply, provide the `threadTimestamp` of the +parent message and the `timestamp` of the specific reply. + +**Required scope:** `chat:history` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.reaction.list( + chat_id="chatId", + timestamp=1, +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**chat_id:** `str` — The chat containing the message. + +
+
+ +
+
+ +**timestamp:** `int` — Timestamp of the message (Unix microseconds). + +
+
+ +
+
+ +**thread_timestamp:** `typing.Optional[int]` — Timestamp of the parent thread message (Unix microseconds), if listing reactions on a thread reply. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Asset +
client.asset.create(...) -> CreateAssetResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a file asset and get back a self-describing instruction for +uploading its bytes — the JSON-friendly way to attach a file (image, PDF, +document, …) to a message, supply media for a story, or host an avatar +image. Unlike [`/item.upload`](https://developer.ro.am/docs/api/item-upload), which takes raw +bytes in the request body, every caller-visible step here is JSON in / +JSON out (so it can be driven from MCP and other tool-calling clients), +and the file bytes never pass through this API. + +**Flow:** +1. `POST /asset.create` with the file `name` (include the extension, e.g. + `photo.png`) and, if known, its `size` in bytes. For stories, also pass + `purpose: "story"`. For avatars, pass `purpose: "avatar"` and `size` + (max 10 MiB). The response + is an upload instruction: `assetId`, `uploadUrl`, `uploadMethod`, and + `uploadHeaders`. Avatar responses also include `imageUrl`. +2. Upload the raw bytes in a **single request**: use `uploadMethod` (a + `POST`) against `uploadUrl`, send every header from `uploadHeaders` + verbatim, and put the file in the request body. Send the headers exactly + as given — they authorize the upload and select the single-request + upload protocol; omitting any will cause the upload to fail. +3. Processing (thumbnails, previews, 512×512 WebP for avatars) happens + automatically once the bytes land. There is no separate "complete" call. +4. Once the asset is ready, use it: + - `purpose: "file"` (default) — attach via `assetIds` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) or + [`/chat.update`](https://developer.ro.am/docs/api/chat-update) + - `purpose: "story"` — post via [`/story.post`](https://developer.ro.am/docs/api/story-post) + - `purpose: "avatar"` — pass `imageUrl` as `sender.imageUrl` on + [`/chat.post`](https://developer.ro.am/docs/api/chat-post) (and related send endpoints), or + as `hosts[].imageUrl` on + [`/onair.event.create`](https://developer.ro.am/docs/onair-api/onair-event-create) / + [`/onair.event.update`](https://developer.ro.am/docs/onair-api/onair-event-update) + +A freshly-uploaded asset may take a few seconds to process (videos take +longer). Chat and story endpoints that consume the asset return a 400 with +a "still processing" message until processing completes. Avatar `imageUrl` +404s until the image is ready — wait a moment after the upload returns +before posting it. + +The `uploadUrl` is short-lived; if it expires, call `asset.create` again for +a fresh instruction. Maximum file size is 5 GiB for `file` / `story`, and +10 MiB for `avatar`. + +## Purposes + +| Purpose | Use | Access | +|---------|-----|--------| +| `file` (default) | Chat message attachments | Organization and Personal | +| `story` | Story media (photo or video) | Personal only | +| `avatar` | `sender.imageUrl` and On-Air `hosts.imageUrl` | Organization and Personal | + +Story assets are owned by the authenticated user (stories are posted as you, +not as a bot) and expire about 48 hours after creation. Because the media +must outlive the story's 24-hour lifetime, call +[`/story.post`](https://developer.ro.am/docs/api/story-post) within about 23 hours of creating the +asset; after that the asset is rejected and a new one must be created. + +Avatar assets are public 512×512 WebP images. They do not expire. From +API version `2026-08-25`, `sender.imageUrl` and On-Air `hosts.imageUrl` +must be a Roam-hosted avatar URL (this `imageUrl`, or a legacy +`/card-images/` or `/photos/people/` URL). Third-party image URLs return +400. See [API Versioning](https://developer.ro.am/docs/guides/api-versioning) and +[Sender Profiles](https://developer.ro.am/docs/guides/sender-profiles). + +**Access:** Organization and Personal. `purpose: "story"` is Personal only. + +**Required scope:** `item:write` for `purpose: "file"`; `chat:send_message` +or `chat:write` for `purpose: "story"`; any of `item:write`, +`chat:send_message`, `chat:write`, or `onair:write` for `purpose: "avatar"`. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.asset.create( + name="quarterly-report.pdf", + size=248173, +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**name:** `str` — File name, including its extension (e.g. `report.pdf`). Processing determines the media type from the extension. + +
+
+ +
+
+ +**size:** `typing.Optional[int]` + +File size in bytes, if known. The true size is enforced +server-side during the upload. Maximum 5 GiB. Required for +`purpose: "avatar"` (maximum 10 MiB). + +
+
+ +
+
+ +**purpose:** `typing.Optional[CreateAssetRequestPurpose]` + +What the asset will be used for. `file` (default) for chat +message attachments; `story` for story media (Personal tokens +only); `avatar` for `sender.imageUrl` and On-Air host photos. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Item +
client.item.upload(...) -> ChatItem +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Upload a file so that it can be sent as a chat message attachment. +The returned object contains an item ID which can be used with [chat.post](https://developer.ro.am/docs/api/chat-post). + +Unlike other endpoints, this uses raw binary upload with metadata in HTTP headers +rather than JSON. This is more efficient for file transfers. + +**Limits:** +- Maximum file size: 10 MB + +**Supported Content Types:** + +| Content-Type | In-Product Behavior | +|--------------|---------------------| +| `image/png`, `image/jpeg`, `image/gif`, `image/webp` | Displayed inline with preview thumbnail | +| `application/octet-stream` | Download link only (no preview) | + +**Important:** Use `application/octet-stream` for **any file type not listed above** (e.g., `.txt`, `.docx`, `.xlsx`, `.zip`, `.pdf`, etc.). +These files will be stored and downloadable, but won't have in-product preview functionality. + +**Validation:** +- The `Content-Type` header must match the actual file content (server validates this for images) +- For images, if the filename lacks the correct extension, it will be appended automatically + +**Required scope:** `item:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +client.item.upload(...) +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]` — The raw binary file content (not base64 encoded, not multipart) + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Story +
client.story.post(...) -> PostStoryResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Posts a story to your Roam. Stories are short photo or video updates that appear +above your profile picture for your teammates, and expire 24 hours after posting. + +## Posting Flow + +1. Create the media asset with [asset.create](https://developer.ro.am/docs/api/asset-create) using + `purpose: "story"`, and upload the file bytes using the returned upload instructions. +2. Call this endpoint with the `assetId` (and an optional `caption`). + +The media must be a photo or a video (videos up to 2.5 minutes; media is optimized +to portrait 1080×1920). If the upload is still processing — typical for videos in +the first seconds after upload — this endpoint returns a 400 with a "still +processing" message; retry after a short delay. + +The media must outlive the story's 24-hour lifetime, so post within about 23 hours +of creating the asset (story assets expire about 48 hours after creation); older +assets are rejected and must be recreated. + +**Access:** Personal only. Stories are always posted as the authenticated user — +a story appears above *your* profile picture, and there is no bot persona surface +for stories — so organization tokens are rejected. + +**Required scope:** `chat:send_message` or `chat:write` (the same permission that +gates sending a chat message) +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.story.post( + asset_id="019be84b-0fa8-788f-8850-96de4cc39130", + caption="Greetings from the offsite 👋", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**asset_id:** `str` + +ID of a processed asset created via [asset.create](https://developer.ro.am/docs/api/asset-create) +with `purpose: "story"`. The asset must be owned by the authenticated user. + +
+
+ +
+
+ +**caption:** `typing.Optional[str]` — Optional caption displayed with the story. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## User +
client.user.list(...) -> ListUserResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List workspace members, or hydrate an explicit ordered set of principal IDs. + +Without `ids`, this is the active workspace member directory: guests, +bots, and archived/deactivated members are never enumerated. Members are +returned in the order they were added to the account. + +With `ids`, the endpoint becomes an unpaginated principal hydrator. Pass one +comma-separated value containing at most 100 bare or tagged IDs. Duplicate +tokens are deduplicated in first-seen order; resolved entries are returned +in that order. Unknown IDs, groups, and unauthorized principals are silently +omitted. Explicit lookup may resolve archived/deactivated users and +authorized automated actors. The response keeps the existing `users` key +but its entries are principals, and `nextCursor` is omitted. + +`ids` cannot be combined with `q`, `limit`, or `cursor`. `expand=status` +remains supported in either mode. + +See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + +**Required scope:** `user:read` (add `user:read.email` to include email addresses, `user:read.status` to expand presence status and `willReturn`) + +**Access:** Organization and Personal. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.user.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**ids:** `typing.Optional[str]` + +One comma-separated list of up to 100 bare or tagged principal IDs. +Repeating the `ids` query parameter, including empty tokens, or combining +it with `q`, `limit`, or `cursor` returns `invalid_parameter`. + +
+
+ +
+
+ +**q:** `typing.Optional[str]` + +Case-insensitive member-directory filter by name. Also matches email +when the token has `user:read.email`. Cannot be combined with `ids`. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — The number of directory members to return per response. Default is 10. Cannot be combined with `ids`. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque directory cursor from a previous response's `nextCursor`. Cannot be combined with `ids`. + +
+
+ +
+
+ +**expand:** `typing.Optional[str]` — Comma-separated list of additional fields. Supported: `status` (requires `user:read.status`). Expanding `status` also returns `willReturn` when set. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.user.info(...) -> User +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Resolve a v1 principal by ID, or look up a workspace member by email. + +ID lookup resolves active or archived members, guests, and authorized +automated actors (classic bots, agents, assistants, and coworkers). The +response always includes `type: "user" | "bot"`; guests additionally have +`isGuest: true`. Groups, unknown IDs, and automated actors outside the +caller's Roam/account/owner boundary return `user_not_found`. + +Email lookup remains workspace-member-only. Personal access tokens and the +MCP `user_info` tool may use ID lookup. + +Provide either `id` or `email`, not both. + +See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals) for the +taxonomy, visibility rules, and directory-versus-hydration guidance. + +**Required scope:** `user:read` (add `user:read.email` to look up by email or include email in response, `user:read.status` to expand presence status, availability, and `willReturn`) + +**Access:** Organization and Personal. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.user.info() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `typing.Optional[str]` — A bare or tagged principal ID. Mutually exclusive with `email`. + +
+
+ +
+
+ +**email:** `typing.Optional[str]` — The user's email address. Mutually exclusive with `id`. Requires `user:read.email` scope. + +
+
+ +
+
+ +**expand:** `typing.Optional[str]` — Comma-separated list of additional fields to include. Supported: `status`, `available` (each requires `user:read.status`). Expanding `status` also returns `willReturn` when the user has a future out-of-office entry. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Users +
client.users.user_activity_set(...) -> UserActivity +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Paint a badge (and optional glow) on a user's seat for work happening +outside Roam — a phone call, a browser meeting, a CRM session. Pass +`dnd: true` to also put their assigned office in Do Not Disturb. + +The integration owns the lifecycle: `set` when the session starts, +`clear` when it ends. Re-posting the same `externalId` is the heartbeat +for long-running sessions — it refreshes `expiresAt` and, unless you +send `startedAt`, keeps the original start time. Roam stamps expiry +itself (default 10 minutes, maximum 60) so a dropped "ended" webhook +cannot leave a permanent glow. + +`externalId` is unique per (integration, user). Two apps can hold +activities on the same person at once; you can only update or clear +your own rows. + +See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, +TTL, stacking, and where the indicator appears on the map. + +**Access:** Organization and Personal. Organization tokens may target +any user in the workspace. Personal tokens (OAuth or PAT) may target +only the token owner. + +**Required scope:** `user:write.activity`. Personal Access Tokens skip +this check; personal-mode OAuth installs must still request the scope. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient, UserActivityDisplay +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.users.user_activity_set( + user_id="0cc74785-e31e-4403-aa5e-0cc7c1897e66", + external_id="justcall:call:CA123", + display=UserActivityDisplay( + emoji="📞", + title="On a customer call", + subtitle="JustCall · Acme Corp", + color="green", + ), + ttl_seconds=1800, + dnd=True, +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**user_id:** `str` + +Target user. Bare or tagged UUID. Personal tokens may only +pass their own user. + +
+
+ +
+
+ +**external_id:** `str` + +Caller-chosen session id, unique per integration and user. +Re-using it upserts the existing row (heartbeat). At most +128 Unicode code points. + +
+
+ +
+
+ +**display:** `UserActivityDisplay` + +
+
+ +
+
+ +**ttl_seconds:** `typing.Optional[int]` + +Seconds from now until expiry. Mutually exclusive with +`expiresAt`. Values above 3600 are **clamped** to 60 +minutes, not rejected. Default when both are omitted: 600 +(10 minutes). + +
+
+ +
+
+ +**expires_at:** `typing.Optional[datetime.datetime]` + +Absolute expiry (RFC3339, must be in the future). Mutually +exclusive with `ttlSeconds`. Instants more than 60 minutes +ahead are clamped to that maximum. + +
+
+ +
+
+ +**started_at:** `typing.Optional[datetime.datetime]` + +Optional session start (RFC3339). Omit on heartbeats to +preserve the original. A future value is clamped to the +server's now (clock skew; also so one integration cannot +pin the newest-first projection slot). + +
+
+ +
+
+ +**dnd:** `typing.Optional[bool]` + +If true, this activity contributes Do Not Disturb on the +user's **own assigned office** until it is cleared or +expires. Defaults to false — a badge does not lock an +office unless you opt in. Stacks with Zoom/Meet auto-DND +and other integrations' DND-flagged rows. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.users.user_activity_clear(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +End an activity previously created with [`user.activity.set`](https://developer.ro.am/docs/api/user-activity-set). +The row is keyed by this integration plus `userId` and `externalId` — +you cannot clear another app's activity. + +Clearing a missing, already-cleared, or already-expired `externalId` +still returns **204**. Integrations retry "session ended" webhooks, and +the row may have expired in the meantime. + +See [External activity](https://developer.ro.am/docs/guides/user-activity) for TTL, DND +stacking, and what happens on the map when the last activity clears. + +**Access:** Organization and Personal. Organization tokens may target +any user in the workspace. Personal tokens (OAuth or PAT) may target +only the token owner. + +**Required scope:** `user:write.activity`. Personal Access Tokens skip +this check; personal-mode OAuth installs must still request the scope. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.users.user_activity_clear( + user_id="0cc74785-e31e-4403-aa5e-0cc7c1897e66", + external_id="justcall:call:CA123", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**user_id:** `str` + +Target user. Bare or tagged UUID. Personal tokens may only +pass their own user. + +
+
+ +
+
+ +**external_id:** `str` — The `externalId` previously passed to `user.activity.set`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.users.user_activity_list(...) -> UserActivityListResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Return every **currently live** external activity for a user — every +integration's rows, not only yours. Expired rows are omitted even +before the server reaper runs. Not paginated; ordered newest +`startedAt` first. + +The map may show fewer entries than this list (the client projection +keeps the top three, always including at least one DND-flagged row). +`.list` is the source of truth for what is still live. + +See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, +TTL, and where indicators appear. + +**Access:** Organization and Personal. Organization tokens may list +any user in the workspace. Personal tokens (OAuth or PAT) may list +only the token owner. + +**Required scope:** `user:read.activity`. Personal Access Tokens skip +this check; personal-mode OAuth installs must still request the scope. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.users.user_activity_list( + user_id="userId", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**user_id:** `str` + +Target user. Bare or tagged UUID. Personal tokens may only pass +their own user. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.users.messageevent_export(...) -> str +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Obtain a daily message event export containing DMs and group +chats within your account. + +For customers with archival enabled (please reach out to a Roam +ArchiTech to get this process started), at the end of every day, +we export all message events for a particular day as a JSON Lines file. +This file contains all messages sent: +- by a Roam user who is a member of your organization +- into a chat containing (at the time of export) at least one Roam user who is a member of your organization +- by a bot integration that is part of your organization + +This file also contains message edit and deletion events that meet the above criteria. +We specifically exclude waves, room invitations, and other non-message content +(that may appear as chats within the Roam application) from the export. + +**Access:** Organization only. + +**Required scope:** `admin:compliance:read` + +### Message Event Structure + +Each line within the file is a JSON object containing the following fields: +- eventType: a string that is one of “sent”, “edited”, or “deleted” +- chatId: a UUIDv4 identifier for a particular chat. All messages within the same chat shared the same chatId. +- threadTimestamp (optional): if part of a thread, the Unix epoch timestamp of the thread’s parent message in numerical format. All messages part of a thread share the same threadTimestamp. +- timestamp: the Unix epoch timestamp when the message was originally sent in numerical format. +- messageId: an internal UUIDv4 identifier as a string +- sender: a “Participant” object that identifiers the message sender +- contentType: a string that is one of the contentTypes associated with the “MessageContent” object +- content: a “MessageContent” object that contains the message’s content + +### Participant + +A Participant is a JSON object that contains three common fields: “participantType”, “id”, and “displayName” +- participantType: one of “email”, “bot”, or “occupant” +- id: a UUID identifier for the participant +- displayName: the name associated with the account or an empty string if not provided + +Depending on the participant type, the object also contains additional fields: + +Email Participant (a human user with a Roam user account) +- email: the email of the participant + +Bot Participant (an automated user maintained by the Roam team or created via the Roam API) +- roamId: the roam ID associated with the integration +- integrationId: a unique integration ID name provided by the bot creator +- botCode: a unique identifier + +### Message Content + +A “MessageContent” object is a JSON object that contains the field “contentType” and, +depending on the content type, contains additional fields: + +*Text Content* (contentType = “text”) +- text: the text in plaintext +- markdownText: the text in Markdown format +- attachments: A list of attachment objects + +*Emoji Content* (contentType = “emoji”) +- text: text representation of the emoji +- colons: emoji in :emoji: format +- fileUrl: an optional field containing the URL to a custom emoji image + +*Item Content* (contentType = “item”) +- itemUrl: the URL where the file can be downloaded from +- itemType: the type of item (e.g. "photo", "pdf", "blob", "video", "audio", etc.) + +*Text Snippet Content* (contentType = "textSnippet") +- text: the content of the snippet +- language: the language of the snippet + +*Members Changed Content* (contentType = “membersChanged”) +- added: a list of Participant objects corresponding to all participants added in this event +- removed: a list of Participant objects corresponding to all participants removed in this event +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.users.messageevent_export( + date="2026-01-21", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**date:** `str` — The UTC date to fetch the export for in YYYY-MM-DD format. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## UserAuditLog +
client.user_audit_log.list(...) -> ListUserAuditLogResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get a list of user audit log entries for the account. + +**Required scope:** `userauditlog:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.user_audit_log.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**date:** `typing.Optional[str]` — The date to pull audit log entries from. All activities from that date in UTC are returned. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Conversation +
client.conversation.list(...) -> ListConversationResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Lists conversations (meetings) that occurred in your Roam, with participant details. + +**Access:** +- **Organization with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)** + (or a grandfathered roam-wide API key): all conversations in the workspace. +- **Personal access tokens:** supported — returns only conversations the + token owner participated in (matched by confirmed email). +- **Organization without roam-wide meeting access** must use + [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead (`403`). + +**Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) + +Participant details require `user:read` scope. Email addresses require `user:read.email` scope. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.conversation.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**before:** `typing.Optional[datetime.datetime]` — Only return conversations that started before this ISO-8601 timestamp. + +
+
+ +
+
+ +**after:** `typing.Optional[datetime.datetime]` — Only return conversations that started after this ISO-8601 timestamp. + +
+
+ +
+
+ +**ascending:** `typing.Optional[bool]` — Sort results in ascending order by start time. Default is descending (newest first). + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — The number of conversations to return per response. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Meeting +
client.meeting.list(...) -> ListMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List meetings, ordered newest-first. + +**Access:** Organization and Personal. Personal tokens return meetings the +authenticated user participated in. Organization tokens return every meeting +in the Roam only with [`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread); +without it, results are limited to meetings the install's bot has access to. + +**Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access) +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**before:** `typing.Optional[datetime.datetime]` — Only return meetings that started before this time (RFC-3339). Sub-millisecond precision is truncated. + +
+
+ +
+
+ +**after:** `typing.Optional[datetime.datetime]` — Only return meetings that started after this time (RFC-3339). Sub-millisecond precision is truncated. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` + +Number of meetings to return per page. Capped to **10** when +`expand` includes `summary`, `actionItems`, or `chapters`, since +expanded payloads are substantially larger. + +
+
+ +
+
+ +**expand:** `typing.Optional[str]` + +Comma-separated list of fields to inline on each meeting. Allowed +values are `summary`, `actionItems`, and `chapters` — same shape +as on [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). Use this to +avoid N+1 follow-up calls when scanning many recent meetings. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.info(...) -> InfoMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get detailed information about a specific meeting, including AI-generated summary, action items, and chapters. + +Participants are included inline up to the `maxParticipants` limit. For meetings with more participants, use [`/meeting.participants`](https://developer.ro.am/docs/api/meeting-participants) to paginate through the full list. + +**Access:** Organization and Personal. Personal tokens are limited to meetings +the authenticated user participated in. Organization tokens without +[`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread) +are limited to meetings the install's bot has access to. + +**Required scope:** `meetings:read` (add `admin:meetings:read` for roam-wide org access; add `user:read` to include participants, `user:read.email` for participant emails) +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.info( + id="id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The meeting ID. + +
+
+ +
+
+ +**max_participants:** `typing.Optional[int]` — Maximum number of participants to resolve and include inline. Use `/meeting.participants` for full pagination. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.participants(...) -> ParticipantsMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Paginate through all participants of a meeting. This is the dedicated endpoint for retrieving the full participant list, complementing the capped inline participants in [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + +Pagination uses an **opaque cursor** (not a row offset). Pass `nextCursor` +from a previous response as `cursor` to fetch the next page. Invalid cursors +return `error: "invalid_cursor"` — see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + +**Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + +**Required scope:** `meetings:read` and `user:read` (add `user:read.email` for participant emails) +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.participants( + id="id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The meeting ID. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — Number of participants to return per page (default 50, max 200). + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not parse or construct cursors yourself. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.transcript(...) -> TranscriptMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Retrieve the transcript for a meeting. + +Supports content negotiation: +- **JSON** (default): Returns structured transcript with cues containing speaker IDs, text, and timing +- **WebVTT**: Set `Accept: text/vtt` header to receive standard WebVTT format with speaker voice tags + +**Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + +**Required scope:** `meetings:read` + +**Errors** (see [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors)): + +| `error` code | Meaning | +|--------------|---------| +| `meeting_not_found` | Unknown or inaccessible meeting | +| `transcript_pending` | Not ready yet — retry later (may include `Retry-After`) | +| `transcript_unavailable` | Meeting was not transcribed — stop retrying | +| `upstream_timeout` | Timed out waiting on an upstream service — retry | +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.transcript( + id="id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The meeting ID. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.search(...) -> SearchMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +AI-powered search across meeting transcripts and summaries. + +**Access:** Personal access only. Organization (account-level) tokens are not supported. + +**Required scope:** `meetings:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.search( + query="query", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**query:** `str` — Search query string. + +
+
+ +
+
+ +**after:** `typing.Optional[datetime.date]` — Only return results from meetings after this date (YYYY-MM-DD). + +
+
+ +
+
+ +**before:** `typing.Optional[datetime.date]` — Only return results from meetings before this date (YYYY-MM-DD). + +
+
+ +
+
+ +**timezone:** `typing.Optional[str]` — Timezone for date interpretation (e.g. "America/New_York"). + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.prompt(...) -> PromptMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Ask an AI question about a meeting's transcript content. Returns a natural language response based on the meeting transcript. + +**Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + +**Required scope:** `meetings:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.prompt( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + prompt="What action items were assigned to Alex?", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The meeting ID. + +
+
+ +
+
+ +**prompt:** `str` — The question to ask about the meeting. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.share_link(...) -> ShareLinkMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Returns a shareable URL for a meeting that you can distribute to others. Pass the `id` of a meeting obtained from [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) or [`/meeting.info`](https://developer.ro.am/docs/api/meeting-info). + +This endpoint is **get-or-create**: it returns the meeting's existing share link, or mints one the first time it is called for that meeting. Repeat calls for the same meeting return the same URL. + +Creating a share link is a deliberate action, which is why it has its own endpoint rather than being returned as a field on `meeting.list` / `meeting.info` — fetching a meeting never mints a shareable link as a side effect. You can only create a share link for a meeting you can access; the same access check as `meeting.info` applies. + +**Access:** Organization and Personal. Personal access tokens restrict to meetings the authenticated user participated in. + +**Required scope:** `meetings:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.share_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The meeting ID. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.create_link(...) -> CreateLinkMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a meeting link. + +**Access:** Organization and Personal. In Organization mode, specify the host by email. In Personal mode, the host defaults to the authenticated user. + +**Required scope:** `meeting:write` or `meetinglink:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment +import datetime + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.create_link( + name="Q1 Planning Session", + host="alex.chen@example.com", + start=datetime.datetime.fromisoformat("2026-02-15T14:00:00+00:00"), + end=datetime.datetime.fromisoformat("2026-02-15T15:00:00+00:00"), +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**name:** `str` — Meeting Name + +
+
+ +
+
+ +**host:** `typing.Optional[str]` + +Meeting Host Email, matching a member of your Roam. + +Required for Organization tokens. For Personal tokens, this is optional and defaults to the authenticated user. If provided with a Personal token, it must match the authenticated user's email. + +
+
+ +
+
+ +**start:** `typing.Optional[datetime.datetime]` — (Optional) Meeting start time in RFC3339. + +
+
+ +
+
+ +**end:** `typing.Optional[datetime.datetime]` — (Optional) Meeting end time in RFC3339. + +
+
+ +
+
+ +**require_unconfirmed_email:** `typing.Optional[bool]` — (Optional) If true, guests must verify ownership of their email address before joining. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.link_info(...) -> LinkInfoMeetingResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get a meeting link. + +**Access:** Organization and Personal. Personal tokens may only read meeting links where the authenticated user is the host. + +**Required scope:** `meetinglink:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.link_info( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — Meeting Link ID + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.meeting.update_link(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Update a meeting link. + +**Access:** Organization and Personal. Personal tokens may only update meeting links where the authenticated user is the host. + +**Required scope:** `meetinglink:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment +import datetime + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meeting.update_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + name="Q1 Planning Session - Updated", + start=datetime.datetime.fromisoformat("2026-02-15T15:00:00+00:00"), + end=datetime.datetime.fromisoformat("2026-02-15T16:30:00+00:00"), +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — Meeting Link ID + +
+
+ +
+
+ +**name:** `str` — Meeting Name + +
+
+ +
+
+ +**host:** `typing.Optional[str]` + +(Optional) Meeting Host Email. + +The Host may NOT be updated. +As a result, this property may be omitted or empty. +If it is provided, it MUST match the existing value. + +
+
+ +
+
+ +**start:** `typing.Optional[datetime.datetime]` — (Optional) Meeting start time in RFC3339. + +
+
+ +
+
+ +**end:** `typing.Optional[datetime.datetime]` — (Optional) Meeting end time in RFC3339. + +
+
+ +
+
+ +**require_unconfirmed_email:** `typing.Optional[bool]` — (Optional) If true, guests must verify ownership of their email address before joining. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Meetings +
client.meetings.recording_list(...) -> RecordingListResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +**Legacy:** Prefer [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) / +[`/meeting.info`](https://developer.ro.am/docs/api/meeting-info) for new integrations. + +Lists recordings in your home Roam, filtered by date range (after/before). +Organization clients without roam-wide meeting access +([`admin:meetings:read`](https://developer.ro.am/docs/guides/scopes#meeting-width-adminmeetingsread)) +receive `403`; use [`/meeting.list`](https://developer.ro.am/docs/api/meeting-list) instead. +This route remains registered for existing callers. It returns v0-style +identifiers and is not a v1 media-download path. + +The plural alias `/recordings.list` is also registered for existing callers; +use this singular form in new documentation and tooling. + +The ordering of results depends on the filter specified: + +- When no parameters are provided, the most recent recordings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + +- If `after` is specified, the results are sorted in forward chronological order. + +Either dates or datetimes may be specified. Dates are interpreted in UTC. + +**Access:** Organization only. Requires roam-wide meeting access. + +**Required scope:** `recordings:read` and `admin:meetings:read` (or a grandfathered roam-wide API key) +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.meetings.recording_list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**after:** `typing.Optional[str]` + +The datetime to begin listing recordings (YYYY-MM-DD or RFC-3339). +Defaults to "no filter". + +
+
+ +
+
+ +**before:** `typing.Optional[str]` + +The datetime until which to list recordings (YYYY-MM-DD or RFC-3339). +Defaults to "now". + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — The number of recordings to return per response. Default is 10. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Calendar +
client.calendar.create_event(...) -> CreateEventCalendarResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a calendar event on the host's connected calendar. A Roam meeting link +is automatically attached and email notifications are sent to attendees. + +The event is written to the first active, writable calendar associated with the +host. The host must have a connected calendar provider (e.g. Google, Microsoft). + +**Recurring events:** Provide `rrule` to create a recurring series. A +`timeZone` is required for recurring events. + +**All-day events:** Set `allDay: true`; `start` and `end` are interpreted as +dates and normalized to UTC midnight. + +**Access:** Organization and Personal. For Organization tokens, the `host` email +is required and identifies the calendar owner. For Personal tokens, `host` +defaults to the authenticated user; if provided it must match the +authenticated user's email. + +**Required scope:** `calendar:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment +import datetime + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.calendar.create_event( + title="Q1 Planning", + description="Plan Q1 roadmap", + start=datetime.datetime.fromisoformat("2026-02-15T14:00:00+00:00"), + end=datetime.datetime.fromisoformat("2026-02-15T15:00:00+00:00"), + time_zone="America/Los_Angeles", + attendees=[ + "sam@example.com", + "Alex Doe " + ], + host="host@example.com", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**title:** `str` — Event title. + +
+
+ +
+
+ +**start:** `datetime.datetime` — Event start time (RFC3339). For all-day events, the date portion is used. + +
+
+ +
+
+ +**end:** `datetime.datetime` — Event end time (RFC3339). For all-day events, the date portion is used. + +
+
+ +
+
+ +**description:** `typing.Optional[str]` — (Optional) Event description. + +
+
+ +
+
+ +**all_day:** `typing.Optional[bool]` — Whether this is an all-day event. Defaults to false. + +
+
+ +
+
+ +**rrule:** `typing.Optional[str]` + +(Optional) iCalendar RFC 5545 recurrence rule, e.g. `FREQ=WEEKLY;COUNT=10`. +When provided, `timeZone` is required. + +
+
+ +
+
+ +**time_zone:** `typing.Optional[str]` + +IANA timezone name, e.g. `America/New_York`. Required for recurring +events; recommended for all events. Defaults to `UTC` when omitted. + +
+
+ +
+
+ +**attendees:** `typing.Optional[typing.List[str]]` + +Attendee email addresses. Each entry may be a plain email +(`user@example.com`) or an address string (`Name `). + +
+
+ +
+
+ +**host:** `typing.Optional[str]` + +Calendar host email. Required for Organization tokens. For Personal +tokens, defaults to the authenticated user and, if provided, must +match the authenticated user's email. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.calendar.list(...) -> ListCalendarResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List events from the authenticated user's connected calendars within +a date range. + +Pulls events from every active personal calendar attached to the user +(e.g. Google, Microsoft) and merges them into a single chronological +list. Canceled events are omitted. + +**Date range:** Defaults to a 7-day window starting today (caller's +timezone). Pass `startDate` to shift the window's start; pass +`endDate` to set its end (inclusive). Both are interpreted as +`YYYY-MM-DD` in the caller's timezone. + +**Access:** Personal access only. Organization tokens do not have +access to individual calendars and receive a `400`. + +**Required scope:** `calendar:read` + +`meetings:read` also grants this endpoint, but only for API clients +registered **before 2026-07-29T00:00Z**. Clients registered on or after that +date must hold `calendar:read`, or the call fails with `403` / +`missing_scope`. See [Scopes](https://developer.ro.am/docs/guides/scopes). +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.calendar.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**start_date:** `typing.Optional[str]` — First day to include (`YYYY-MM-DD`, caller's timezone). Defaults to today. + +
+
+ +
+
+ +**end_date:** `typing.Optional[str]` + +Last day to include (`YYYY-MM-DD`, caller's timezone, inclusive). +Defaults to seven days after the resolved `startDate`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Lobby +
client.lobby.list(...) -> ListLobbyResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Lists active lobbies in your account. + +A lobby URL has the form `ro.am/{handle}` or `ro.am/{handle}/{slug}`. +- The "handle" is the first path segment +- The "slug" is the optional second path segment. It may be empty for the default lobby under a handle + +Optionally filter by a specific lobby handle. If provided, only lobbies +associated with that handle are returned. + +This endpoint is **not paginated**. The 200 body is `{ "lobbies": [...] }` +with every matching lobby; there is no `cursor` / `nextCursor` and no +`data` array. The TypeScript SDK returns that object directly, not a +page helper. + +**Access:** Organization and Personal. + +**Required scope:** `lobby:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.lobby.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**handle:** `typing.Optional[str]` + +Filter by lobby handle (first path segment), e.g., `robfig` for +`ro.am/robfig` or `ro.am/robfig/tour`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.lobby.list_bookings(...) -> ListBookingsLobbyResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Lists bookings for a specific lobby configuration, filtered by date range (after/before). + +The ordering of results depends on the filter specified: + +- When no parameters are provided, the most recent bookings are returned, + sorted in reverse chronological order. This is equivalent to specifying `before` + as NOW and leaving `after` unspecified. + +- If `after` is specified, the results are sorted in forward chronological order. + +Either dates or datetimes may be specified. Dates are interpreted in UTC. + +**Access:** Organization and Personal. + +**Required scope:** `lobby:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.lobby.list_bookings( + lobby_id="lobbyId", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**lobby_id:** `str` — The lobby configuration ID to list bookings for. + +
+
+ +
+
+ +**after:** `typing.Optional[datetime.datetime]` + +The datetime to begin listing bookings (YYYY-MM-DD or RFC-3339). +Defaults to "no filter". + +
+
+ +
+
+ +**before:** `typing.Optional[datetime.datetime]` + +The datetime until which to list bookings (YYYY-MM-DD or RFC-3339). +Defaults to "now". + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — The number of bookings to return per response. Default is 10. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Magicast +
client.magicast.list(...) -> ListMagicastResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List Magicasts in your account, most recent first. + +Returns metadata only (`id`, `name`, `createdAt`, `ownerId`, +`coverImageUrl`). Use [`/magicast.info`](https://developer.ro.am/docs/api/magicast-info) for +transcript cues, chapters, video status, and a signed download URL. + +**Access:** Organization and Personal. Organization tokens list every +Magicast in the account, including ones the creator never shared. Personal +tokens are restricted to Magicasts owned by the authenticated user. + +**Required scope:** `magicast:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.magicast.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**after:** `typing.Optional[datetime.datetime]` — Only return magicasts created after this time (RFC-3339). + +
+
+ +
+
+ +**before:** `typing.Optional[datetime.datetime]` — Only return magicasts created before this time (RFC-3339). + +
+
+ +
+
+ +**ascending:** `typing.Optional[bool]` — Sort oldest-first instead of newest-first. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — Number of magicasts to return per response. Default 10. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.magicast.info(...) -> MagicastInfo +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get details for a single Magicast by ID, including transcript cues, +chapters, video status, a signed video download URL when ready, and a +player URL if a share link already exists. + +This is the content endpoint. [`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) +returns metadata only. Magicasts are not meetings — they do not appear on +[`/recording.list`](https://developer.ro.am/docs/api/recording-list) or meeting transcript +surfaces, and they have no Magic Minutes summary or action items. + +Asset, transcript, and share-link lookups are best-effort. If the video or +transcript is still processing, those fields are omitted and the request +still succeeds. Fetching this endpoint **never** mints a shareable link; +use [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link) for that. + +There is no `https://ro.am/magicast/{id}` browser URL. The player URL is +always `https://ro.am/share/{key}`. + +**Access:** Organization and Personal. Organization tokens can read every +Magicast in the account, including ones the creator never shared. Personal +tokens are restricted to Magicasts owned by the authenticated user. Filter +on whether `shareUrl` is present if you only want shared recordings. + +**Required scope:** `magicast:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.magicast.info( + id="id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The magicast ID. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Magicasts +
client.magicasts.magicast_share_link(...) -> MagicastShareLinkResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Returns a shareable player URL for a Magicast. Pass the `id` obtained from +[`/magicast.list`](https://developer.ro.am/docs/api/magicast-list) or +[`/magicast.info`](https://developer.ro.am/docs/api/magicast-info). + +This endpoint is **get-or-create**: it returns the Magicast's existing +share link, or mints one the first time it is called. Repeat calls for the +same Magicast return the same URL. + +Creating a share link is a deliberate action, which is why it has its own +endpoint rather than being returned as a field that is always present on +`magicast.list` / `magicast.info`. Fetching a Magicast never mints a +shareable link as a side effect. `magicast.info` includes `shareUrl` only +when a link already exists. + +The URL is `https://ro.am/share/{key}`. There is no +`https://ro.am/magicast/{id}` route. + +You can only create a share link for a Magicast you can access; the same +access check as `magicast.info` applies. + +**Access:** Organization and Personal. Personal access tokens restrict to +Magicasts owned by the authenticated user. + +**Required scope:** `magicast:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.magicasts.magicast_share_link( + id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The Magicast ID. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Group +
client.group.list(...) -> ListGroupResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Lists non-archived groups accessible to the caller. + +Filter by name with `query` (ranked text match), restrict by group +type with `type`, and paginate with `limit` / `cursor`. + +**Access:** Organization and Personal. + +**Required scope:** `group:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**query:** `typing.Optional[str]` — Text filter. Groups are ranked by how well their name matches the query. + +
+
+ +
+
+ +**type:** `typing.Optional[str]` + +Comma-separated list of group types to include. Must be one or +more of `standard`, `magicast`, `meeting`, `roam`, `onair`. +Defaults to all types. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — Number of groups to return per page (default 50, max 100). + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.info(...) -> Group +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get information about a specific group by its ID or name. + +Provide either `id` or `name`, not both. + +**Required scope:** `group:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.info() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `typing.Optional[str]` — The group's ID. Mutually exclusive with `name`. + +
+
+ +
+
+ +**name:** `typing.Optional[str]` — The group's name. Mutually exclusive with `id`. Returns first match if multiple groups have the same name. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.create(...) -> Group +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a group chat. + +Groups which specify at least one admin will operate in an "Admin only" management +mode, where only admins may change settings. Otherwise, all members have +that capability. + +Groups require at least one member. Users can be specified by user ID or email address. + +**Required scope:** `group:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment +from roamhq.group import CreateGroupRequestMembersItem + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.create( + name="Engineering Team", + description="Group chat for engineering discussions and updates", + private=False, + enforce_threads=True, + members=[ + CreateGroupRequestMembersItem( + user_id="alex.chen@example.com", + role="member", + ), + CreateGroupRequestMembersItem( + user_id="taylor@example.com", + role="member", + ), + CreateGroupRequestMembersItem( + user_id="jordan.smith@example.com", + role="admin", + ) + ], +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**name:** `str` — Name of the group + +
+
+ +
+
+ +**members:** `typing.List[CreateGroupRequestMembersItem]` — Group members with their roles + +
+
+ +
+
+ +**description:** `typing.Optional[str]` — Description of the group + +
+
+ +
+
+ +**private:** `typing.Optional[bool]` — Whether the group is private (default false) + +
+
+ +
+
+ +**enforce_threads:** `typing.Optional[bool]` — Whether to enforce threaded conversations + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.rename(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Rename a group by ID. + +Apps may only rename groups for which they are an admin. + +**Required scope:** `group:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.rename( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + name="Product Engineering", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The group ID + +
+
+ +
+
+ +**name:** `str` — The new name for the group + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.archive(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Archive a group by ID. + +Apps may only archive groups for which they are an admin. + +**Required scope:** `group:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.archive( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — The group ID to archive. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.members(...) -> MembersGroupResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List members in a group with their roles. + +Apps may list members if one of the following conditions is true: +1. It is a public group in their Roam. +2. They are a member of the group. + +**Required scope:** `group:read` + +Every returned `userId` is a visible principal ID that resolves through +[`user.info`](https://developer.ro.am/docs/api/user-info) with the same credentials. Use +`user.list?ids` for ordered bulk hydration. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.members( + id="id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — Group ID. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — The number of members to return per response. Default is 10. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. Do not construct cursors manually. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.add(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Add one or more group members with specified roles. + +Members can be specified by user ID or email address. Each member must be assigned a role (member or admin). + +Apps may add members to a group if one of the following conditions is true: +1. It is a public group in their Roam. +2. They are a member of the group. + +If attempting to add an admin, the app must be an admin of the group. + +**Required scope:** `group:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment +from roamhq.group import AddGroupRequestMembersItem + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.add( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + members=[ + AddGroupRequestMembersItem( + user_id="709b8a57-70bc-427a-b6f0-b16ba5297f8c", + role="member", + ), + AddGroupRequestMembersItem( + user_id="f589a8cb-78ac-493e-8719-0fa8a22f65e0", + role="member", + ), + AddGroupRequestMembersItem( + user_id="af6663d5-0f37-4105-95df-4fea20ef7c7c", + role="admin", + ) + ], +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — Group ID + +
+
+ +
+
+ +**members:** `typing.Optional[typing.List[AddGroupRequestMembersItem]]` — List of members to add with their roles + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.join(...) -> Group +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Join a public group as the calling identity (Slack `conversations.join`). + +- Org tokens add the bot address as a member. +- Personal tokens add the **owner person**, never the PAT bot address. +- Private groups cannot be self-joined (`403`). +- Idempotent if the calling identity is already a member. +- Non-members of a group in another roam receive an opaque `403` + (`group_not_found`) — archived / type / privacy are not distinguished. + +Why join (webhooks vs history vs post): [Chat](https://developer.ro.am/docs/guides/chat). + +**Access:** Organization and Personal. + +**Required scope:** `group:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.join( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — Group ID + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.group.remove(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Remove one or more group members. + +Members can be specified by user ID or email address. + +Apps may remove members from a group if one of the following conditions is true: +1. It is a public group in their Roam. +2. They are a member of the group. + +Removing members with the Admin role is not yet supported. + +**Required scope:** `group:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.group.remove( + id="88bebce7-6cbb-4666-96f9-5c02d73e6661", + members=[ + "709b8a57-70bc-427a-b6f0-b16ba5297f8c" + ], +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — Group ID + +
+
+ +
+
+ +**members:** `typing.List[str]` — List of member IDs or email addresses to remove + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Groups +
client.groups.list() -> typing.List[GroupsListResponseItem] +
+
+ +#### 📝 Description + +
+
+ +
+
+ +**Legacy:** Prefer [`/group.list`](https://developer.ro.am/docs/api/group-list) for new integrations. + +Lists all public, non-archived groups in your home Roam. + +Unlike `/group.list`, this endpoint returns a **raw JSON array** (not the +`{"ok": true, …}` envelope). It is the sole ok-envelope exception on `/v1` +and remains only for existing callers. + +**Access:** Organization only. + +**Required scope:** `group:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.groups.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Token +
client.token.info() -> InfoTokenResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get information about the access token, including the authenticated user/bot +and granted scopes. + +**No specific scope required.** +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.token.info() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.token.revoke() +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Permanently revoke the presented OAuth access token **and its refresh +token**. After a successful response the grant is dead — refresh will not +resurrect it; the client must re-authorize. + +This does **not** uninstall your app from the workspace, delete webhook +subscriptions, or affect other users' tokens. For install removal see the +[`app.uninstalled`](https://developer.ro.am/docs/webhooks/app-uninstalled) event (fired from admin +/ Dev Settings uninstall paths, not from this endpoint). + +On success Roam also delivers a [`token.revoked`](https://developer.ro.am/docs/webhooks/token-revoked) +webhook to your subscriptions (`reason: "api_revoked"`), including to the +same app that called this endpoint. Treat that delivery as idempotent. + +Subsequent API calls with the revoked access token return HTTP `401` with +`invalid_token` (the token row is gone). Distinct from `token_revoked`, +which signals an archived person or archived client while a credential may +still exist. + +This operation is only valid for OAuth access tokens, not for API keys. + +**Access:** Organization and Personal (OAuth access tokens only). Personal +tokens may revoke their own grant. API keys cannot use this endpoint. + +**No specific scope required.** +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.token.revoke() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +## Webhook +
client.webhook.list() -> ListWebhookResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List all webhook subscriptions owned by the authenticated API client. + +The response includes both **dynamic** subscriptions (created via +[`/webhook.subscribe`](https://developer.ro.am/docs/webhooks/webhook-subscribe)) and **static** +subscriptions configured in the Roam Administration UI. + +Each object may include `lastSuccessAt`, `failStreakStartedAt`, and +`disabledAt` (omitted when null). `disabledAt` means the destination is +paused. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + +**Required scope:** `webhook:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.webhook.list() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.webhook.subscribe(...) -> Webhook +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create or update a webhook subscription for a given event. If a subscription +already exists for the same event and URL, its filter is updated instead of +creating a duplicate. Re-subscribing the same event and URL also clears a +pause (`disabledAt` / `failStreakStartedAt`) so deliveries resume on the +next event. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + +**Event names are dotted:** `chat.message`, `lobby.booked`, +`magicast.created`. Colon names (`chat:message:dm`, `lobby:booked`) are +v0-only — sending them here returns `400` / `Unrecognized event`. + +Roam does not probe the destination URL when you subscribe — the +subscription is created immediately and the first delivery is a real event. + +See the [Webhooks overview](https://developer.ro.am/docs/webhooks/webhooks) for the full list of event names and their filters. + +**Required scope:** `webhook:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient, WebhookSubscriptionFilter +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.webhook.subscribe( + url="https://example.com/hooks/messages", + event="chat.message", + filter=WebhookSubscriptionFilter( + mention=True, + ), +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**url:** `str` — Destination URL for webhook deliveries (max 1024 characters). HTTPS is required outside local environments. + +
+
+ +
+
+ +**event:** `WebhookSubscriptionRequestEvent` — Event to subscribe to. + +
+
+ +
+
+ +**filter:** `typing.Optional[WebhookSubscriptionFilter]` + +
+
+ +
+
+ +**api_version:** `typing.Optional[str]` + +Optional [API version](https://developer.ro.am/docs/guides/api-versioning) (`YYYY-MM-DD`) to pin +this subscription's payload shape to. When omitted, the subscription is +frozen at your integration's default version. Unsupported values return +`400`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.webhook.unsubscribe(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Remove a webhook subscription by ID. + +The request body is JSON: `{"id": ""}`. This differs +from v0, which expects `application/x-www-form-urlencoded` with the same +`id` field. Sending JSON to `/v0/webhook.unsubscribe` returns +`id parameter required`. + +**Required scope:** `webhook:write` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.webhook.unsubscribe( + id="19c6401f-6d02-4d8c-87c5-9fc45f02f4b5", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `str` — Identifier of the webhook subscription to remove. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.webhook.deliveries(...) -> DeliveriesWebhookResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List recent **failed** webhook delivery attempts for the authenticated API +client, newest first. Use this to debug a misbehaving endpoint and to find +the events you need to replay: successful (2xx) deliveries are never +recorded, so every row here is a delivery your endpoint did not accept. + +Timeouts are first-class failures: `statusCode` is `0` and `error` is +`timeout`. For HTTP error responses, a truncated copy of your server's +response body is included to aid debugging. The request payload is never +stored — to recover the data, re-fetch the underlying resource (e.g. via +`chat.history`) using the delivery's `messageId`/`event` context. + +Results are strictly scoped to the caller's own subscriptions and retained +for roughly 30 days. + +**Access:** Organization and Personal. + +**Required scope:** `webhook:read` +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from roamhq import RoamClient +from roamhq.environment import RoamClientEnvironment + +client = RoamClient( + token="", + environment=RoamClientEnvironment.DEFAULT, +) + +client.webhook.deliveries() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**webhook:** `typing.Optional[str]` — Only return deliveries for this webhook subscription ID. + +
+
+ +
+
+ +**event:** `typing.Optional[str]` — Only return deliveries for this event name (e.g. `chat.message`). + +
+
+ +
+
+ +**after:** `typing.Optional[str]` — Only return deliveries after this time (RFC3339 or `YYYY-MM-DD`). Results switch to oldest-first. + +
+
+ +
+
+ +**before:** `typing.Optional[str]` — Only return deliveries before this time (RFC3339 or `YYYY-MM-DD`). + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — Maximum number of deliveries to return. + +
+
+ +
+
+ +**cursor:** `typing.Optional[str]` — Opaque pagination cursor from a previous response's `nextCursor`. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ diff --git a/src/roamhq/story/__init__.py b/src/roamhq/story/__init__.py new file mode 100644 index 0000000..5034720 --- /dev/null +++ b/src/roamhq/story/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import PostStoryResponse +_dynamic_imports: typing.Dict[str, str] = {"PostStoryResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["PostStoryResponse"] diff --git a/src/roamhq/story/client.py b/src/roamhq/story/client.py new file mode 100644 index 0000000..0ff7c13 --- /dev/null +++ b/src/roamhq/story/client.py @@ -0,0 +1,185 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawStoryClient, RawStoryClient +from .types.post_story_response import PostStoryResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class StoryClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawStoryClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawStoryClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawStoryClient + """ + return self._raw_client + + def post( + self, + *, + asset_id: str, + caption: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PostStoryResponse: + """ + Posts a story to your Roam. Stories are short photo or video updates that appear + above your profile picture for your teammates, and expire 24 hours after posting. + + ## Posting Flow + + 1. Create the media asset with [asset.create](https://developer.ro.am/docs/api/asset-create) using + `purpose: "story"`, and upload the file bytes using the returned upload instructions. + 2. Call this endpoint with the `assetId` (and an optional `caption`). + + The media must be a photo or a video (videos up to 2.5 minutes; media is optimized + to portrait 1080×1920). If the upload is still processing — typical for videos in + the first seconds after upload — this endpoint returns a 400 with a "still + processing" message; retry after a short delay. + + The media must outlive the story's 24-hour lifetime, so post within about 23 hours + of creating the asset (story assets expire about 48 hours after creation); older + assets are rejected and must be recreated. + + **Access:** Personal only. Stories are always posted as the authenticated user — + a story appears above *your* profile picture, and there is no bot persona surface + for stories — so organization tokens are rejected. + + **Required scope:** `chat:send_message` or `chat:write` (the same permission that + gates sending a chat message) + + Parameters + ---------- + asset_id : str + ID of a processed asset created via [asset.create](https://developer.ro.am/docs/api/asset-create) + with `purpose: "story"`. The asset must be owned by the authenticated user. + + caption : typing.Optional[str] + Optional caption displayed with the story. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PostStoryResponse + The story was posted. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.story.post( + asset_id="019be84b-0fa8-788f-8850-96de4cc39130", + caption="Greetings from the offsite 👋", + ) + """ + _response = self._raw_client.post(asset_id=asset_id, caption=caption, request_options=request_options) + return _response.data + + +class AsyncStoryClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawStoryClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawStoryClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawStoryClient + """ + return self._raw_client + + async def post( + self, + *, + asset_id: str, + caption: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PostStoryResponse: + """ + Posts a story to your Roam. Stories are short photo or video updates that appear + above your profile picture for your teammates, and expire 24 hours after posting. + + ## Posting Flow + + 1. Create the media asset with [asset.create](https://developer.ro.am/docs/api/asset-create) using + `purpose: "story"`, and upload the file bytes using the returned upload instructions. + 2. Call this endpoint with the `assetId` (and an optional `caption`). + + The media must be a photo or a video (videos up to 2.5 minutes; media is optimized + to portrait 1080×1920). If the upload is still processing — typical for videos in + the first seconds after upload — this endpoint returns a 400 with a "still + processing" message; retry after a short delay. + + The media must outlive the story's 24-hour lifetime, so post within about 23 hours + of creating the asset (story assets expire about 48 hours after creation); older + assets are rejected and must be recreated. + + **Access:** Personal only. Stories are always posted as the authenticated user — + a story appears above *your* profile picture, and there is no bot persona surface + for stories — so organization tokens are rejected. + + **Required scope:** `chat:send_message` or `chat:write` (the same permission that + gates sending a chat message) + + Parameters + ---------- + asset_id : str + ID of a processed asset created via [asset.create](https://developer.ro.am/docs/api/asset-create) + with `purpose: "story"`. The asset must be owned by the authenticated user. + + caption : typing.Optional[str] + Optional caption displayed with the story. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + PostStoryResponse + The story was posted. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.story.post( + asset_id="019be84b-0fa8-788f-8850-96de4cc39130", + caption="Greetings from the offsite 👋", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.post(asset_id=asset_id, caption=caption, request_options=request_options) + return _response.data diff --git a/src/roamhq/story/raw_client.py b/src/roamhq/story/raw_client.py new file mode 100644 index 0000000..984824a --- /dev/null +++ b/src/roamhq/story/raw_client.py @@ -0,0 +1,331 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.post_story_response import PostStoryResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawStoryClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def post( + self, + *, + asset_id: str, + caption: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PostStoryResponse]: + """ + Posts a story to your Roam. Stories are short photo or video updates that appear + above your profile picture for your teammates, and expire 24 hours after posting. + + ## Posting Flow + + 1. Create the media asset with [asset.create](https://developer.ro.am/docs/api/asset-create) using + `purpose: "story"`, and upload the file bytes using the returned upload instructions. + 2. Call this endpoint with the `assetId` (and an optional `caption`). + + The media must be a photo or a video (videos up to 2.5 minutes; media is optimized + to portrait 1080×1920). If the upload is still processing — typical for videos in + the first seconds after upload — this endpoint returns a 400 with a "still + processing" message; retry after a short delay. + + The media must outlive the story's 24-hour lifetime, so post within about 23 hours + of creating the asset (story assets expire about 48 hours after creation); older + assets are rejected and must be recreated. + + **Access:** Personal only. Stories are always posted as the authenticated user — + a story appears above *your* profile picture, and there is no bot persona surface + for stories — so organization tokens are rejected. + + **Required scope:** `chat:send_message` or `chat:write` (the same permission that + gates sending a chat message) + + Parameters + ---------- + asset_id : str + ID of a processed asset created via [asset.create](https://developer.ro.am/docs/api/asset-create) + with `purpose: "story"`. The asset must be owned by the authenticated user. + + caption : typing.Optional[str] + Optional caption displayed with the story. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[PostStoryResponse] + The story was posted. + """ + _response = self._client_wrapper.httpx_client.request( + "story.post", + method="POST", + json={ + "assetId": asset_id, + "caption": caption, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PostStoryResponse, + parse_obj_as( + type_=PostStoryResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawStoryClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def post( + self, + *, + asset_id: str, + caption: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PostStoryResponse]: + """ + Posts a story to your Roam. Stories are short photo or video updates that appear + above your profile picture for your teammates, and expire 24 hours after posting. + + ## Posting Flow + + 1. Create the media asset with [asset.create](https://developer.ro.am/docs/api/asset-create) using + `purpose: "story"`, and upload the file bytes using the returned upload instructions. + 2. Call this endpoint with the `assetId` (and an optional `caption`). + + The media must be a photo or a video (videos up to 2.5 minutes; media is optimized + to portrait 1080×1920). If the upload is still processing — typical for videos in + the first seconds after upload — this endpoint returns a 400 with a "still + processing" message; retry after a short delay. + + The media must outlive the story's 24-hour lifetime, so post within about 23 hours + of creating the asset (story assets expire about 48 hours after creation); older + assets are rejected and must be recreated. + + **Access:** Personal only. Stories are always posted as the authenticated user — + a story appears above *your* profile picture, and there is no bot persona surface + for stories — so organization tokens are rejected. + + **Required scope:** `chat:send_message` or `chat:write` (the same permission that + gates sending a chat message) + + Parameters + ---------- + asset_id : str + ID of a processed asset created via [asset.create](https://developer.ro.am/docs/api/asset-create) + with `purpose: "story"`. The asset must be owned by the authenticated user. + + caption : typing.Optional[str] + Optional caption displayed with the story. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[PostStoryResponse] + The story was posted. + """ + _response = await self._client_wrapper.httpx_client.request( + "story.post", + method="POST", + json={ + "assetId": asset_id, + "caption": caption, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + PostStoryResponse, + parse_obj_as( + type_=PostStoryResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/story/types/__init__.py b/src/roamhq/story/types/__init__.py new file mode 100644 index 0000000..11fa4d6 --- /dev/null +++ b/src/roamhq/story/types/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .post_story_response import PostStoryResponse +_dynamic_imports: typing.Dict[str, str] = {"PostStoryResponse": ".post_story_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["PostStoryResponse"] diff --git a/src/roamhq/story/types/post_story_response.py b/src/roamhq/story/types/post_story_response.py new file mode 100644 index 0000000..b17c235 --- /dev/null +++ b/src/roamhq/story/types/post_story_response.py @@ -0,0 +1,49 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class PostStoryResponse(UniversalBaseModel): + item_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="itemId"), + pydantic.Field(alias="itemId", description="ID of the created story item."), + ] = None + """ + ID of the created story item. + """ + + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the Roam's story chat the story was posted into."), + ] = None + """ + ID of the Roam's story chat the story was posted into. + """ + + expires_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="expiresAt"), + pydantic.Field(alias="expiresAt", description="When the story expires (24 hours after posting)."), + ] = None + """ + When the story expires (24 hours after posting). + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/token/__init__.py b/src/roamhq/token/__init__.py new file mode 100644 index 0000000..fb4c649 --- /dev/null +++ b/src/roamhq/token/__init__.py @@ -0,0 +1,41 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import InfoTokenResponse, InfoTokenResponseBot, InfoTokenResponseRoam, InfoTokenResponseUser +_dynamic_imports: typing.Dict[str, str] = { + "InfoTokenResponse": ".types", + "InfoTokenResponseBot": ".types", + "InfoTokenResponseRoam": ".types", + "InfoTokenResponseUser": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["InfoTokenResponse", "InfoTokenResponseBot", "InfoTokenResponseRoam", "InfoTokenResponseUser"] diff --git a/src/roamhq/token/client.py b/src/roamhq/token/client.py new file mode 100644 index 0000000..b92f9b5 --- /dev/null +++ b/src/roamhq/token/client.py @@ -0,0 +1,216 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawTokenClient, RawTokenClient +from .types.info_token_response import InfoTokenResponse + + +class TokenClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawTokenClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawTokenClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawTokenClient + """ + return self._raw_client + + def info(self, *, request_options: typing.Optional[RequestOptions] = None) -> InfoTokenResponse: + """ + Get information about the access token, including the authenticated user/bot + and granted scopes. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + InfoTokenResponse + Token info retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.token.info() + """ + _response = self._raw_client.info(request_options=request_options) + return _response.data + + def revoke(self, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Permanently revoke the presented OAuth access token **and its refresh + token**. After a successful response the grant is dead — refresh will not + resurrect it; the client must re-authorize. + + This does **not** uninstall your app from the workspace, delete webhook + subscriptions, or affect other users' tokens. For install removal see the + [`app.uninstalled`](https://developer.ro.am/docs/webhooks/app-uninstalled) event (fired from admin + / Dev Settings uninstall paths, not from this endpoint). + + On success Roam also delivers a [`token.revoked`](https://developer.ro.am/docs/webhooks/token-revoked) + webhook to your subscriptions (`reason: "api_revoked"`), including to the + same app that called this endpoint. Treat that delivery as idempotent. + + Subsequent API calls with the revoked access token return HTTP `401` with + `invalid_token` (the token row is gone). Distinct from `token_revoked`, + which signals an archived person or archived client while a credential may + still exist. + + This operation is only valid for OAuth access tokens, not for API keys. + + **Access:** Organization and Personal (OAuth access tokens only). Personal + tokens may revoke their own grant. API keys cannot use this endpoint. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.token.revoke() + """ + _response = self._raw_client.revoke(request_options=request_options) + return _response.data + + +class AsyncTokenClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawTokenClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawTokenClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawTokenClient + """ + return self._raw_client + + async def info(self, *, request_options: typing.Optional[RequestOptions] = None) -> InfoTokenResponse: + """ + Get information about the access token, including the authenticated user/bot + and granted scopes. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + InfoTokenResponse + Token info retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.token.info() + + + asyncio.run(main()) + """ + _response = await self._raw_client.info(request_options=request_options) + return _response.data + + async def revoke(self, *, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Permanently revoke the presented OAuth access token **and its refresh + token**. After a successful response the grant is dead — refresh will not + resurrect it; the client must re-authorize. + + This does **not** uninstall your app from the workspace, delete webhook + subscriptions, or affect other users' tokens. For install removal see the + [`app.uninstalled`](https://developer.ro.am/docs/webhooks/app-uninstalled) event (fired from admin + / Dev Settings uninstall paths, not from this endpoint). + + On success Roam also delivers a [`token.revoked`](https://developer.ro.am/docs/webhooks/token-revoked) + webhook to your subscriptions (`reason: "api_revoked"`), including to the + same app that called this endpoint. Treat that delivery as idempotent. + + Subsequent API calls with the revoked access token return HTTP `401` with + `invalid_token` (the token row is gone). Distinct from `token_revoked`, + which signals an archived person or archived client while a credential may + still exist. + + This operation is only valid for OAuth access tokens, not for API keys. + + **Access:** Organization and Personal (OAuth access tokens only). Personal + tokens may revoke their own grant. API keys cannot use this endpoint. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.token.revoke() + + + asyncio.run(main()) + """ + _response = await self._raw_client.revoke(request_options=request_options) + return _response.data diff --git a/src/roamhq/token/raw_client.py b/src/roamhq/token/raw_client.py new file mode 100644 index 0000000..67a8af3 --- /dev/null +++ b/src/roamhq/token/raw_client.py @@ -0,0 +1,374 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.info_token_response import InfoTokenResponse +from pydantic import ValidationError + + +class RawTokenClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def info(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[InfoTokenResponse]: + """ + Get information about the access token, including the authenticated user/bot + and granted scopes. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[InfoTokenResponse] + Token info retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "token.info", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + InfoTokenResponse, + parse_obj_as( + type_=InfoTokenResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def revoke(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: + """ + Permanently revoke the presented OAuth access token **and its refresh + token**. After a successful response the grant is dead — refresh will not + resurrect it; the client must re-authorize. + + This does **not** uninstall your app from the workspace, delete webhook + subscriptions, or affect other users' tokens. For install removal see the + [`app.uninstalled`](https://developer.ro.am/docs/webhooks/app-uninstalled) event (fired from admin + / Dev Settings uninstall paths, not from this endpoint). + + On success Roam also delivers a [`token.revoked`](https://developer.ro.am/docs/webhooks/token-revoked) + webhook to your subscriptions (`reason: "api_revoked"`), including to the + same app that called this endpoint. Treat that delivery as idempotent. + + Subsequent API calls with the revoked access token return HTTP `401` with + `invalid_token` (the token row is gone). Distinct from `token_revoked`, + which signals an archived person or archived client while a credential may + still exist. + + This operation is only valid for OAuth access tokens, not for API keys. + + **Access:** Organization and Personal (OAuth access tokens only). Personal + tokens may revoke their own grant. API keys cannot use this endpoint. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "token.revoke", + method="POST", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawTokenClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def info( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[InfoTokenResponse]: + """ + Get information about the access token, including the authenticated user/bot + and granted scopes. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[InfoTokenResponse] + Token info retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "token.info", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + InfoTokenResponse, + parse_obj_as( + type_=InfoTokenResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def revoke(self, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[None]: + """ + Permanently revoke the presented OAuth access token **and its refresh + token**. After a successful response the grant is dead — refresh will not + resurrect it; the client must re-authorize. + + This does **not** uninstall your app from the workspace, delete webhook + subscriptions, or affect other users' tokens. For install removal see the + [`app.uninstalled`](https://developer.ro.am/docs/webhooks/app-uninstalled) event (fired from admin + / Dev Settings uninstall paths, not from this endpoint). + + On success Roam also delivers a [`token.revoked`](https://developer.ro.am/docs/webhooks/token-revoked) + webhook to your subscriptions (`reason: "api_revoked"`), including to the + same app that called this endpoint. Treat that delivery as idempotent. + + Subsequent API calls with the revoked access token return HTTP `401` with + `invalid_token` (the token row is gone). Distinct from `token_revoked`, + which signals an archived person or archived client while a credential may + still exist. + + This operation is only valid for OAuth access tokens, not for API keys. + + **Access:** Organization and Personal (OAuth access tokens only). Personal + tokens may revoke their own grant. API keys cannot use this endpoint. + + **No specific scope required.** + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "token.revoke", + method="POST", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/token/types/__init__.py b/src/roamhq/token/types/__init__.py new file mode 100644 index 0000000..31f1e8d --- /dev/null +++ b/src/roamhq/token/types/__init__.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .info_token_response import InfoTokenResponse + from .info_token_response_bot import InfoTokenResponseBot + from .info_token_response_roam import InfoTokenResponseRoam + from .info_token_response_user import InfoTokenResponseUser +_dynamic_imports: typing.Dict[str, str] = { + "InfoTokenResponse": ".info_token_response", + "InfoTokenResponseBot": ".info_token_response_bot", + "InfoTokenResponseRoam": ".info_token_response_roam", + "InfoTokenResponseUser": ".info_token_response_user", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["InfoTokenResponse", "InfoTokenResponseBot", "InfoTokenResponseRoam", "InfoTokenResponseUser"] diff --git a/src/roamhq/token/types/info_token_response.py b/src/roamhq/token/types/info_token_response.py new file mode 100644 index 0000000..b23428f --- /dev/null +++ b/src/roamhq/token/types/info_token_response.py @@ -0,0 +1,56 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .info_token_response_bot import InfoTokenResponseBot +from .info_token_response_roam import InfoTokenResponseRoam +from .info_token_response_user import InfoTokenResponseUser + + +class InfoTokenResponse(UniversalBaseModel): + user: InfoTokenResponseUser = pydantic.Field() + """ + The authenticated user or bot + """ + + bot: typing.Optional[InfoTokenResponseBot] = pydantic.Field(default=None) + """ + The bot persona associated with the token. Present only for personal + access tokens, where `user` is the authenticated person and `bot` is + the persona that messages are posted as. Omitted for organization + tokens, where `user` is the app's own identity. + """ + + client_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="clientId"), + pydantic.Field(alias="clientId", description="The API client (app) ID this token belongs to."), + ] + """ + The API client (app) ID this token belongs to. + """ + + scopes: typing.List[str] = pydantic.Field() + """ + List of OAuth scopes granted to this token + """ + + roam: InfoTokenResponseRoam = pydantic.Field() + """ + Information about the Roam workspace + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/token/types/info_token_response_bot.py b/src/roamhq/token/types/info_token_response_bot.py new file mode 100644 index 0000000..6795f1a --- /dev/null +++ b/src/roamhq/token/types/info_token_response_bot.py @@ -0,0 +1,47 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class InfoTokenResponseBot(UniversalBaseModel): + """ + The bot persona associated with the token. Present only for personal + access tokens, where `user` is the authenticated person and `bot` is + the persona that messages are posted as. Omitted for organization + tokens, where `user` is the app's own identity. + """ + + id: str = pydantic.Field() + """ + Bot ID + """ + + name: str = pydantic.Field() + """ + Bot display name + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="Bot profile image URL"), + ] = None + """ + Bot profile image URL + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/token/types/info_token_response_roam.py b/src/roamhq/token/types/info_token_response_roam.py new file mode 100644 index 0000000..d53d5b6 --- /dev/null +++ b/src/roamhq/token/types/info_token_response_roam.py @@ -0,0 +1,53 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class InfoTokenResponseRoam(UniversalBaseModel): + """ + Information about the Roam workspace + """ + + id: typing.Optional[str] = pydantic.Field(default=None) + """ + The Roam's external ID + """ + + name: typing.Optional[str] = pydantic.Field(default=None) + """ + The Roam's display name + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="URL of the Roam's profile image"), + ] = None + """ + URL of the Roam's profile image + """ + + icon_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="iconUrl"), + pydantic.Field(alias="iconUrl", description="URL of the Roam's icon"), + ] = None + """ + URL of the Roam's icon + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/token/types/info_token_response_user.py b/src/roamhq/token/types/info_token_response_user.py new file mode 100644 index 0000000..135d811 --- /dev/null +++ b/src/roamhq/token/types/info_token_response_user.py @@ -0,0 +1,49 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class InfoTokenResponseUser(UniversalBaseModel): + """ + The authenticated user or bot + """ + + id: str = pydantic.Field() + """ + User/bot ID + """ + + name: str = pydantic.Field() + """ + Display name + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="Profile image URL"), + ] = None + """ + Profile image URL + """ + + email: typing.Optional[str] = pydantic.Field(default=None) + """ + Email address. Included for personal access tokens with the `user:read.email` scope. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/__init__.py b/src/roamhq/types/__init__.py new file mode 100644 index 0000000..a2b425a --- /dev/null +++ b/src/roamhq/types/__init__.py @@ -0,0 +1,190 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .action_item import ActionItem + from .address import Address + from .address_type import AddressType + from .chat_item import ChatItem + from .chat_item_type import ChatItemType + from .chat_message import ChatMessage + from .chat_message_content_type import ChatMessageContentType + from .chat_message_poll import ChatMessagePoll + from .chat_message_poll_options_item import ChatMessagePollOptionsItem + from .chat_message_sender import ChatMessageSender + from .chat_message_type import ChatMessageType + from .chat_message_user_type import ChatMessageUserType + from .chat_message_voice import ChatMessageVoice + from .error import Error + from .group import Group + from .group_access_mode import GroupAccessMode + from .group_group_management import GroupGroupManagement + from .group_member import GroupMember + from .group_member_role import GroupMemberRole + from .group_type import GroupType + from .lobby_booking import LobbyBooking + from .lobby_booking_host import LobbyBookingHost + from .lobby_booking_invitee import LobbyBookingInvitee + from .lobby_booking_response import LobbyBookingResponse + from .lobby_booking_response_type import LobbyBookingResponseType + from .lobby_booking_response_value import LobbyBookingResponseValue + from .magicast import Magicast + from .magicast_chapter import MagicastChapter + from .magicast_cue import MagicastCue + from .magicast_info import MagicastInfo + from .magicast_info_video_status import MagicastInfoVideoStatus + from .meeting_participant import MeetingParticipant + from .meeting_participant_type import MeetingParticipantType + from .reaction import Reaction + from .sender import Sender + from .unfurl_content import UnfurlContent + from .unfurl_content_image import UnfurlContentImage + from .user import User + from .user_activity import UserActivity + from .user_activity_display import UserActivityDisplay + from .user_activity_display_color import UserActivityDisplayColor + from .user_audit_log import UserAuditLog + from .user_audit_log_platform import UserAuditLogPlatform + from .user_status import UserStatus + from .user_type import UserType + from .user_will_return import UserWillReturn + from .webhook import Webhook + from .webhook_event import WebhookEvent + from .webhook_subscription_filter import WebhookSubscriptionFilter + from .webhook_subscription_filter_chat_type import WebhookSubscriptionFilterChatType + from .webhook_subscription_filter_status import WebhookSubscriptionFilterStatus +_dynamic_imports: typing.Dict[str, str] = { + "ActionItem": ".action_item", + "Address": ".address", + "AddressType": ".address_type", + "ChatItem": ".chat_item", + "ChatItemType": ".chat_item_type", + "ChatMessage": ".chat_message", + "ChatMessageContentType": ".chat_message_content_type", + "ChatMessagePoll": ".chat_message_poll", + "ChatMessagePollOptionsItem": ".chat_message_poll_options_item", + "ChatMessageSender": ".chat_message_sender", + "ChatMessageType": ".chat_message_type", + "ChatMessageUserType": ".chat_message_user_type", + "ChatMessageVoice": ".chat_message_voice", + "Error": ".error", + "Group": ".group", + "GroupAccessMode": ".group_access_mode", + "GroupGroupManagement": ".group_group_management", + "GroupMember": ".group_member", + "GroupMemberRole": ".group_member_role", + "GroupType": ".group_type", + "LobbyBooking": ".lobby_booking", + "LobbyBookingHost": ".lobby_booking_host", + "LobbyBookingInvitee": ".lobby_booking_invitee", + "LobbyBookingResponse": ".lobby_booking_response", + "LobbyBookingResponseType": ".lobby_booking_response_type", + "LobbyBookingResponseValue": ".lobby_booking_response_value", + "Magicast": ".magicast", + "MagicastChapter": ".magicast_chapter", + "MagicastCue": ".magicast_cue", + "MagicastInfo": ".magicast_info", + "MagicastInfoVideoStatus": ".magicast_info_video_status", + "MeetingParticipant": ".meeting_participant", + "MeetingParticipantType": ".meeting_participant_type", + "Reaction": ".reaction", + "Sender": ".sender", + "UnfurlContent": ".unfurl_content", + "UnfurlContentImage": ".unfurl_content_image", + "User": ".user", + "UserActivity": ".user_activity", + "UserActivityDisplay": ".user_activity_display", + "UserActivityDisplayColor": ".user_activity_display_color", + "UserAuditLog": ".user_audit_log", + "UserAuditLogPlatform": ".user_audit_log_platform", + "UserStatus": ".user_status", + "UserType": ".user_type", + "UserWillReturn": ".user_will_return", + "Webhook": ".webhook", + "WebhookEvent": ".webhook_event", + "WebhookSubscriptionFilter": ".webhook_subscription_filter", + "WebhookSubscriptionFilterChatType": ".webhook_subscription_filter_chat_type", + "WebhookSubscriptionFilterStatus": ".webhook_subscription_filter_status", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "ActionItem", + "Address", + "AddressType", + "ChatItem", + "ChatItemType", + "ChatMessage", + "ChatMessageContentType", + "ChatMessagePoll", + "ChatMessagePollOptionsItem", + "ChatMessageSender", + "ChatMessageType", + "ChatMessageUserType", + "ChatMessageVoice", + "Error", + "Group", + "GroupAccessMode", + "GroupGroupManagement", + "GroupMember", + "GroupMemberRole", + "GroupType", + "LobbyBooking", + "LobbyBookingHost", + "LobbyBookingInvitee", + "LobbyBookingResponse", + "LobbyBookingResponseType", + "LobbyBookingResponseValue", + "Magicast", + "MagicastChapter", + "MagicastCue", + "MagicastInfo", + "MagicastInfoVideoStatus", + "MeetingParticipant", + "MeetingParticipantType", + "Reaction", + "Sender", + "UnfurlContent", + "UnfurlContentImage", + "User", + "UserActivity", + "UserActivityDisplay", + "UserActivityDisplayColor", + "UserAuditLog", + "UserAuditLogPlatform", + "UserStatus", + "UserType", + "UserWillReturn", + "Webhook", + "WebhookEvent", + "WebhookSubscriptionFilter", + "WebhookSubscriptionFilterChatType", + "WebhookSubscriptionFilterStatus", +] diff --git a/src/roamhq/types/action_item.py b/src/roamhq/types/action_item.py new file mode 100644 index 0000000..da4618d --- /dev/null +++ b/src/roamhq/types/action_item.py @@ -0,0 +1,102 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class ActionItem(UniversalBaseModel): + """ + An AI-extracted action item from a meeting. `complete` reflects whether the + task has been marked done. Assignment has two forms: an explicit `assigneeId` + (a user the item was assigned to) and a `suggestedAssigneeId` (an AI-suggested + owner); `suggestedAssigneeName` is the display name for whichever applies. + """ + + id: typing.Optional[str] = pydantic.Field(default=None) + """ + Action item ID. + """ + + title: str = pydantic.Field() + """ + Action item title. + """ + + description: typing.Optional[str] = pydantic.Field(default=None) + """ + Longer description of the action item. + """ + + complete: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether the action item has been marked complete. Omitted when false. + """ + + assignee_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="assigneeId"), + pydantic.Field(alias="assigneeId", description="User ID this item was explicitly assigned to, if any."), + ] = None + """ + User ID this item was explicitly assigned to, if any. + """ + + suggested_assignee_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="suggestedAssigneeId"), + pydantic.Field(alias="suggestedAssigneeId", description="AI-suggested assignee user ID, if any."), + ] = None + """ + AI-suggested assignee user ID, if any. + """ + + suggested_assignee_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="suggestedAssigneeName"), + pydantic.Field( + alias="suggestedAssigneeName", + description="Display name for the assignee — resolved from `assigneeId` when set, otherwise the AI-suggested name.", + ), + ] = None + """ + Display name for the assignee — resolved from `assigneeId` when set, otherwise the AI-suggested name. + """ + + assigned_to_me: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="assignedToMe"), + pydantic.Field( + alias="assignedToMe", + description="Whether this item is assigned to the authenticated user. Personal access tokens only.", + ), + ] = None + """ + Whether this item is assigned to the authenticated user. Personal access tokens only. + """ + + suggested_for_me: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="suggestedForMe"), + pydantic.Field( + alias="suggestedForMe", + description="Whether this item is AI-suggested for the authenticated user. Personal access tokens only.", + ), + ] = None + """ + Whether this item is AI-suggested for the authenticated user. Personal access tokens only. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/address.py b/src/roamhq/types/address.py new file mode 100644 index 0000000..68a18b2 --- /dev/null +++ b/src/roamhq/types/address.py @@ -0,0 +1,98 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .address_type import AddressType + + +class Address(UniversalBaseModel): + """ + A resolved address. Which fields are populated depends on `type`: `user` + addresses include `email`/`isGuest`; `bot` addresses include + `botCode`/`integrationId` when available; group addresses include only the + common fields. Classic bots, agents, assistants, and coworkers all project + as `type: bot`. + """ + + id: str = pydantic.Field() + """ + The address ID. + """ + + type: AddressType = pydantic.Field() + """ + The kind of address. + """ + + display_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="displayName"), + pydantic.Field(alias="displayName", description="Display name of the address."), + ] = None + """ + Display name of the address. + """ + + display_image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="displayImageUrl"), + pydantic.Field(alias="displayImageUrl", description="Display image URL of the address."), + ] = None + """ + Display image URL of the address. + """ + + email: typing.Optional[str] = pydantic.Field(default=None) + """ + Email address. Present on `user` addresses, and only when the token has the `user:read.email` scope. + """ + + is_guest: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="isGuest"), + pydantic.Field( + alias="isGuest", + description="Present and true only when the user has no membership in the caller's account.", + ), + ] = None + """ + Present and true only when the user has no membership in the caller's account. + """ + + bot_code: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="botCode"), + pydantic.Field( + alias="botCode", + description="Classic bot persona identifier, when available. Other bot-like actors may omit it.", + ), + ] = None + """ + Classic bot persona identifier, when available. Other bot-like actors may omit it. + """ + + integration_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="integrationId"), + pydantic.Field( + alias="integrationId", description="Integration/client ID, when available. Some bot-like actors omit it." + ), + ] = None + """ + Integration/client ID, when available. Some bot-like actors omit it. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/address_type.py b/src/roamhq/types/address_type.py new file mode 100644 index 0000000..1c88dfc --- /dev/null +++ b/src/roamhq/types/address_type.py @@ -0,0 +1,9 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +AddressType = typing.Union[ + typing.Literal["user", "bot", "standardGroup", "meetingGroup", "userGroup", "teamRoam"], typing.Any +] diff --git a/src/roamhq/types/chat_item.py b/src/roamhq/types/chat_item.py new file mode 100644 index 0000000..ea6a3aa --- /dev/null +++ b/src/roamhq/types/chat_item.py @@ -0,0 +1,79 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .chat_item_type import ChatItemType + + +class ChatItem(UniversalBaseModel): + id: str = pydantic.Field() + """ + UUID identifying this item + """ + + type: ChatItemType = pydantic.Field() + """ + Type of item: + + - **photo**: Images with inline preview and thumbnail + - image/jpeg, image/png, image/gif, image/webp + + - **blob**: Any other file type (download only, no preview) + - application/octet-stream + """ + + mime: typing.Optional[str] = pydantic.Field(default=None) + """ + MIME type of the file (e.g., "application/octet-stream"). + May be omitted for photo items where the type is inferred from the image format. + """ + + created: dt.datetime = pydantic.Field() + """ + Timestamp when the item was created + """ + + name: str = pydantic.Field() + """ + Name of the item (typically the filename). + """ + + url: str = pydantic.Field() + """ + URL for the uploaded item. + """ + + thumbnail: typing.Optional[str] = pydantic.Field(default=None) + """ + URL for a thumbnail of the uploaded item (photo type only). + This may be equal to the item's main URL if it is suitable to use as a thumbnail. + """ + + size: typing.Optional[int] = pydantic.Field(default=None) + """ + Size of the item in bytes + """ + + width: typing.Optional[int] = pydantic.Field(default=None) + """ + Width in pixels (images only) + """ + + height: typing.Optional[int] = pydantic.Field(default=None) + """ + Height in pixels (images only) + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/chat_item_type.py b/src/roamhq/types/chat_item_type.py new file mode 100644 index 0000000..c352294 --- /dev/null +++ b/src/roamhq/types/chat_item_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ChatItemType = typing.Union[typing.Literal["photo", "blob"], typing.Any] diff --git a/src/roamhq/types/chat_message.py b/src/roamhq/types/chat_message.py new file mode 100644 index 0000000..4237b74 --- /dev/null +++ b/src/roamhq/types/chat_message.py @@ -0,0 +1,173 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .chat_item import ChatItem +from .chat_message_content_type import ChatMessageContentType +from .chat_message_poll import ChatMessagePoll +from .chat_message_sender import ChatMessageSender +from .chat_message_type import ChatMessageType +from .chat_message_user_type import ChatMessageUserType +from .chat_message_voice import ChatMessageVoice + + +class ChatMessage(UniversalBaseModel): + """ + A chat message in the Roam workspace + """ + + type: typing.Optional[ChatMessageType] = pydantic.Field(default=None) + """ + Message type identifier + """ + + user_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="userId"), + pydantic.Field(alias="userId", description="Principal ID of the message sender. Resolve with `user.info`."), + ] + """ + Principal ID of the message sender. Resolve with `user.info`. + """ + + user_type: typing_extensions.Annotated[ + ChatMessageUserType, + FieldMetadata(alias="userType"), + pydantic.Field( + alias="userType", + description="Principal type of `userId`; always equals `user.info.type` for the same credentials. Use it to prevent bot loops without another lookup.", + ), + ] + """ + Principal type of `userId`; always equals `user.info.type` for the same credentials. Use it to prevent bot loops without another lookup. + """ + + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field(alias="chatId", description="ID of the chat the message belongs to"), + ] = None + """ + ID of the chat the message belongs to + """ + + timestamp: int = pydantic.Field() + """ + Message key as Unix microseconds + """ + + thread_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="threadTimestamp"), + pydantic.Field( + alias="threadTimestamp", + description="Unix microseconds timestamp of the parent message (for thread replies)", + ), + ] = None + """ + Unix microseconds timestamp of the parent message (for thread replies) + """ + + reply_timestamp: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="replyTimestamp"), + pydantic.Field( + alias="replyTimestamp", + description="Timestamp of the message this one quotes — a DM or channel-thread quoted reply, set via the `replyTimestamp` request field on chat.post. Omitted otherwise.", + ), + ] = None + """ + Timestamp of the message this one quotes — a DM or channel-thread quoted reply, set via the `replyTimestamp` request field on chat.post. Omitted otherwise. + """ + + ephemeral: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether the message is ephemeral. Always omitted (false) in chat.history, chat.search, and chat.link.resolve responses — ephemeral messages (posted via chat.postEphemeral) are never persisted, so they never appear in these APIs. + """ + + text: typing.Optional[str] = pydantic.Field(default=None) + """ + Text of the message, formatted as github-flavored markdown. Mention tokens use Slack's syntax: `<@ID>` is a principal (user or bot — resolve with `user.info`), `` is a group or channel (resolve with `group.info`), and `` is the broadcast keyword. + """ + + content_type: typing_extensions.Annotated[ + ChatMessageContentType, + FieldMetadata(alias="contentType"), + pydantic.Field( + alias="contentType", + description="Type of message content: `text` (markdown body, optionally with items), `voice` (voice note), `block` (rich block layout), or `poll`.", + ), + ] + """ + Type of message content: `text` (markdown body, optionally with items), `voice` (voice note), `block` (rich block layout), or `poll`. + """ + + items: typing.Optional[typing.List[ChatItem]] = pydantic.Field(default=None) + """ + Items attached to this message + """ + + poll: typing.Optional[ChatMessagePoll] = pydantic.Field(default=None) + """ + Poll content, present when contentType is `poll`. + """ + + voice: typing.Optional[ChatMessageVoice] = pydantic.Field(default=None) + """ + Voice-note content, present when contentType is `voice`. + """ + + blocks: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = pydantic.Field(default=None) + """ + Rich block layout, present when contentType is `block`. + """ + + color: typing.Optional[str] = pydantic.Field(default=None) + """ + Accent color for a block message, present when contentType is `block`. Omitted otherwise. + """ + + reply_count: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="replyCount"), + pydantic.Field( + alias="replyCount", description="Number of replies in this message's thread. Omitted when zero." + ), + ] = None + """ + Number of replies in this message's thread. Omitted when zero. + """ + + sender: typing.Optional[ChatMessageSender] = pydantic.Field(default=None) + """ + Per-message sender display override supplied at send time via the + request's `sender` field. Present only when the stored message carries + one. Additive: `userId` remains the authoring identity — render the + override, attribute with `userId`. See the + [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + """ + + mentions: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Flat, order-preserving, de-duplicated list of everything referenced by + mention tokens in `text`: bare address UUIDs (from both `<@ID>` principal + and `` group tokens), plus the literal `all` for the `` + broadcast keyword. Present when the text contains mentions. Resolve UUIDs + to display info via the response's `addresses` map (request + `expand=addresses`). + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/chat_message_content_type.py b/src/roamhq/types/chat_message_content_type.py new file mode 100644 index 0000000..847da75 --- /dev/null +++ b/src/roamhq/types/chat_message_content_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ChatMessageContentType = typing.Union[typing.Literal["text", "voice", "block", "poll"], typing.Any] diff --git a/src/roamhq/types/chat_message_poll.py b/src/roamhq/types/chat_message_poll.py new file mode 100644 index 0000000..8326a8b --- /dev/null +++ b/src/roamhq/types/chat_message_poll.py @@ -0,0 +1,57 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .chat_message_poll_options_item import ChatMessagePollOptionsItem + + +class ChatMessagePoll(UniversalBaseModel): + """ + Poll content, present when contentType is `poll`. + """ + + question: typing.Optional[str] = pydantic.Field(default=None) + """ + The poll question. + """ + + options: typing.Optional[typing.List[ChatMessagePollOptionsItem]] = pydantic.Field(default=None) + """ + The poll answer options. + """ + + allow_multiple_answers: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="allowMultipleAnswers"), + pydantic.Field(alias="allowMultipleAnswers", description="Whether voters can select multiple options."), + ] = None + """ + Whether voters can select multiple options. + """ + + closes_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="closesAt"), + pydantic.Field( + alias="closesAt", description="When the poll closes (RFC-3339). Omitted if no close time is set." + ), + ] = None + """ + When the poll closes (RFC-3339). Omitted if no close time is set. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/chat_message_poll_options_item.py b/src/roamhq/types/chat_message_poll_options_item.py new file mode 100644 index 0000000..b7df360 --- /dev/null +++ b/src/roamhq/types/chat_message_poll_options_item.py @@ -0,0 +1,29 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class ChatMessagePollOptionsItem(UniversalBaseModel): + id: typing.Optional[str] = pydantic.Field(default=None) + """ + Unique option identifier. + """ + + text: typing.Optional[str] = pydantic.Field(default=None) + """ + Option display text. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/chat_message_sender.py b/src/roamhq/types/chat_message_sender.py new file mode 100644 index 0000000..4fdd830 --- /dev/null +++ b/src/roamhq/types/chat_message_sender.py @@ -0,0 +1,43 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class ChatMessageSender(UniversalBaseModel): + """ + Per-message sender display override supplied at send time via the + request's `sender` field. Present only when the stored message carries + one. Additive: `userId` remains the authoring identity — render the + override, attribute with `userId`. See the + [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + """ + + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Display name override for this message. + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="Avatar URL override for this message."), + ] = None + """ + Avatar URL override for this message. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/chat_message_type.py b/src/roamhq/types/chat_message_type.py new file mode 100644 index 0000000..a3c6de9 --- /dev/null +++ b/src/roamhq/types/chat_message_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ChatMessageType = typing.Union[typing.Literal["message"], typing.Any] diff --git a/src/roamhq/types/chat_message_user_type.py b/src/roamhq/types/chat_message_user_type.py new file mode 100644 index 0000000..5134c3c --- /dev/null +++ b/src/roamhq/types/chat_message_user_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +ChatMessageUserType = typing.Union[typing.Literal["user", "bot"], typing.Any] diff --git a/src/roamhq/types/chat_message_voice.py b/src/roamhq/types/chat_message_voice.py new file mode 100644 index 0000000..9231a99 --- /dev/null +++ b/src/roamhq/types/chat_message_voice.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class ChatMessageVoice(UniversalBaseModel): + """ + Voice-note content, present when contentType is `voice`. + """ + + audio_url: typing_extensions.Annotated[ + str, + FieldMetadata(alias="audioUrl"), + pydantic.Field(alias="audioUrl", description="URL of the voice-note audio (m4a)."), + ] + """ + URL of the voice-note audio (m4a). + """ + + duration: int = pydantic.Field() + """ + Duration of the voice note in milliseconds. + """ + + transcript: typing.Optional[str] = pydantic.Field(default=None) + """ + Text transcript of the voice note. Omitted if not yet available. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/error.py b/src/roamhq/types/error.py new file mode 100644 index 0000000..ae09f4a --- /dev/null +++ b/src/roamhq/types/error.py @@ -0,0 +1,46 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class Error(UniversalBaseModel): + ok: bool = pydantic.Field() + """ + Always `false` on error responses. + """ + + error: str = pydantic.Field() + """ + Machine-readable error code from the catalog + (e.g. `invalid_token`, `missing_scope`, `ratelimited`, `invalid_cursor`). + Branch on this field. See [Responses and Errors](https://developer.ro.am/docs/guides/responses-and-errors). + """ + + needed: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Present on `missing_scope`. Scopes that would satisfy the check. + **Any-of (OR)** semantics: holding any one element is enough. + Distinct from Slack's comma-separated `needed` string. + """ + + provided: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Present on `missing_scope`. The token's granted scopes after alias + normalization (e.g. legacy `groups:read` reports as `group:read`). + For personal tokens this is the expanded OAuth set, not `pat:*` + group names. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/group.py b/src/roamhq/types/group.py new file mode 100644 index 0000000..fbc3318 --- /dev/null +++ b/src/roamhq/types/group.py @@ -0,0 +1,109 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .group_access_mode import GroupAccessMode +from .group_group_management import GroupGroupManagement +from .group_type import GroupType + + +class Group(UniversalBaseModel): + """ + A group (channel) in the Roam workspace + """ + + id: str = pydantic.Field() + """ + The group's unique identifier + """ + + chat_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="chatId"), + pydantic.Field( + alias="chatId", + description="The group's channel chat ID. Populated by `group.create` and\n`group.info` (the channel chat is created together with the group), and\nused to post to or read the channel via the chat endpoints.", + ), + ] = None + """ + The group's channel chat ID. Populated by `group.create` and + `group.info` (the channel chat is created together with the group), and + used to post to or read the channel via the chat endpoints. + """ + + name: str = pydantic.Field() + """ + Name of the group + """ + + type: GroupType = pydantic.Field() + """ + The type of group: + - `standard` - A regular channel created by users + - `magicast` - A Magicast channel + - `meeting` - A meeting channel + - `roam` - The main Roam channel (one per workspace) + - `onair` - An On-Air channel + - `community` - A community channel + """ + + access_mode: typing_extensions.Annotated[ + typing.Optional[GroupAccessMode], + FieldMetadata(alias="accessMode"), + pydantic.Field(alias="accessMode", description="Whether the group is public or private"), + ] = None + """ + Whether the group is public or private + """ + + group_management: typing_extensions.Annotated[ + typing.Optional[GroupGroupManagement], + FieldMetadata(alias="groupManagement"), + pydantic.Field(alias="groupManagement", description="Who can manage group settings and membership"), + ] = None + """ + Who can manage group settings and membership + """ + + enforce_threaded_mode: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="enforceThreadedMode"), + pydantic.Field(alias="enforceThreadedMode", description="Whether the group enforces threaded conversations"), + ] = None + """ + Whether the group enforces threaded conversations + """ + + date_created: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="dateCreated"), + pydantic.Field(alias="dateCreated", description="When the group was created"), + ] = None + """ + When the group was created + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="URL of the group's image"), + ] = None + """ + URL of the group's image + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/group_access_mode.py b/src/roamhq/types/group_access_mode.py new file mode 100644 index 0000000..59ba2e6 --- /dev/null +++ b/src/roamhq/types/group_access_mode.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +GroupAccessMode = typing.Union[typing.Literal["public", "private"], typing.Any] diff --git a/src/roamhq/types/group_group_management.py b/src/roamhq/types/group_group_management.py new file mode 100644 index 0000000..259021c --- /dev/null +++ b/src/roamhq/types/group_group_management.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +GroupGroupManagement = typing.Union[typing.Literal["allMembers", "groupAdminsOnly"], typing.Any] diff --git a/src/roamhq/types/group_member.py b/src/roamhq/types/group_member.py new file mode 100644 index 0000000..e733393 --- /dev/null +++ b/src/roamhq/types/group_member.py @@ -0,0 +1,38 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .group_member_role import GroupMemberRole + + +class GroupMember(UniversalBaseModel): + """ + A member of a group with their role + """ + + user_id: typing_extensions.Annotated[ + str, FieldMetadata(alias="userId"), pydantic.Field(alias="userId", description="The user's unique identifier") + ] + """ + The user's unique identifier + """ + + role: GroupMemberRole = pydantic.Field() + """ + The member's role in the group + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/group_member_role.py b/src/roamhq/types/group_member_role.py new file mode 100644 index 0000000..a288954 --- /dev/null +++ b/src/roamhq/types/group_member_role.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +GroupMemberRole = typing.Union[typing.Literal["member", "admin"], typing.Any] diff --git a/src/roamhq/types/group_type.py b/src/roamhq/types/group_type.py new file mode 100644 index 0000000..41aa278 --- /dev/null +++ b/src/roamhq/types/group_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +GroupType = typing.Union[typing.Literal["standard", "magicast", "meeting", "roam", "onair", "community"], typing.Any] diff --git a/src/roamhq/types/lobby_booking.py b/src/roamhq/types/lobby_booking.py new file mode 100644 index 0000000..7fc148b --- /dev/null +++ b/src/roamhq/types/lobby_booking.py @@ -0,0 +1,82 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .lobby_booking_host import LobbyBookingHost +from .lobby_booking_invitee import LobbyBookingInvitee +from .lobby_booking_response import LobbyBookingResponse + + +class LobbyBooking(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique booking identifier + """ + + start: dt.datetime = pydantic.Field() + """ + Start time in RFC3339 + """ + + end: dt.datetime = pydantic.Field() + """ + End time in RFC3339 + """ + + status: str = pydantic.Field() + """ + Current status of the booking + """ + + time_zone: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="timeZone"), + pydantic.Field(alias="timeZone", description="IANA time zone of the booking times"), + ] = None + """ + IANA time zone of the booking times + """ + + notes: typing.Optional[str] = pydantic.Field(default=None) + """ + Optional notes provided by the booker + """ + + created: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + Creation time + """ + + hosts: typing.Optional[typing.List[LobbyBookingHost]] = None + invitees: typing.Optional[typing.List[LobbyBookingInvitee]] = None + responses: typing.Optional[typing.List[LobbyBookingResponse]] = pydantic.Field(default=None) + """ + The guest's answers to the lobby's custom questions, including hidden fields + populated from URL query parameters on the lobby link. One entry per answered + question; empty or absent when the guest answered no custom questions. + """ + + meeting_link: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="meetingLink"), + pydantic.Field(alias="meetingLink", description="Meeting link URL, used to join the meeting"), + ] = None + """ + Meeting link URL, used to join the meeting + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/lobby_booking_host.py b/src/roamhq/types/lobby_booking_host.py new file mode 100644 index 0000000..e7b4065 --- /dev/null +++ b/src/roamhq/types/lobby_booking_host.py @@ -0,0 +1,40 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class LobbyBookingHost(UniversalBaseModel): + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Display name of the host + """ + + email: str = pydantic.Field() + """ + Email address of the host + """ + + is_organizer: typing_extensions.Annotated[ + bool, + FieldMetadata(alias="isOrganizer"), + pydantic.Field(alias="isOrganizer", description="Whether this host is the organizer"), + ] + """ + Whether this host is the organizer + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/lobby_booking_invitee.py b/src/roamhq/types/lobby_booking_invitee.py new file mode 100644 index 0000000..68c9e83 --- /dev/null +++ b/src/roamhq/types/lobby_booking_invitee.py @@ -0,0 +1,45 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class LobbyBookingInvitee(UniversalBaseModel): + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Display name of the invitee + """ + + email: str = pydantic.Field() + """ + Email address of the invitee + """ + + status: str = pydantic.Field() + """ + Invitee's RSVP or booking status + """ + + is_booker: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="isBooker"), + pydantic.Field(alias="isBooker", description="Whether this invitee created the booking"), + ] = None + """ + Whether this invitee created the booking + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/lobby_booking_response.py b/src/roamhq/types/lobby_booking_response.py new file mode 100644 index 0000000..8d7168e --- /dev/null +++ b/src/roamhq/types/lobby_booking_response.py @@ -0,0 +1,63 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .lobby_booking_response_type import LobbyBookingResponseType +from .lobby_booking_response_value import LobbyBookingResponseValue + + +class LobbyBookingResponse(UniversalBaseModel): + """ + A guest's answer to one of the lobby's custom questions, captured when the booking + was made. Includes answers to hidden fields, which are populated from URL query + parameters on the lobby link (e.g. `?utm_source=partner`). + """ + + field_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="fieldId"), + pydantic.Field(alias="fieldId", description="ID of the custom field (question) this answer belongs to."), + ] + """ + ID of the custom field (question) this answer belongs to. + """ + + key: typing.Optional[str] = pydantic.Field(default=None) + """ + The field's stable key, if the lobby owner assigned one, as captured when the + booking was made. For hidden fields this is the URL query parameter name used to + populate the value. + """ + + question: typing.Optional[str] = pydantic.Field(default=None) + """ + The question's display name. Omitted if the field definition can no longer be + found on the lobby configuration. + """ + + type: typing.Optional[LobbyBookingResponseType] = pydantic.Field(default=None) + """ + The custom field type. Omitted when `question` is omitted. + """ + + value: typing.Optional[LobbyBookingResponseValue] = pydantic.Field(default=None) + """ + The human-readable answer. For option fields (radio, checkbox, dropdown) this is + the selected option label(s), not internal option IDs. Checkbox answers are + arrays of strings; all other answers are strings. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/lobby_booking_response_type.py b/src/roamhq/types/lobby_booking_response_type.py new file mode 100644 index 0000000..3fca5ad --- /dev/null +++ b/src/roamhq/types/lobby_booking_response_type.py @@ -0,0 +1,9 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +LobbyBookingResponseType = typing.Union[ + typing.Literal["short_text", "text", "email", "phone_number", "radio", "checkbox", "dropdown", "hidden"], typing.Any +] diff --git a/src/roamhq/types/lobby_booking_response_value.py b/src/roamhq/types/lobby_booking_response_value.py new file mode 100644 index 0000000..5483111 --- /dev/null +++ b/src/roamhq/types/lobby_booking_response_value.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +LobbyBookingResponseValue = typing.Union[str, typing.List[str]] diff --git a/src/roamhq/types/magicast.py b/src/roamhq/types/magicast.py new file mode 100644 index 0000000..173492a --- /dev/null +++ b/src/roamhq/types/magicast.py @@ -0,0 +1,59 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class Magicast(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique identifier for the magicast + """ + + name: str = pydantic.Field() + """ + Display name of the magicast + """ + + created_at: typing_extensions.Annotated[ + dt.datetime, + FieldMetadata(alias="createdAt"), + pydantic.Field(alias="createdAt", description="ISO-8601 timestamp when the magicast was created (UTC)"), + ] + """ + ISO-8601 timestamp when the magicast was created (UTC) + """ + + owner_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="ownerId"), + pydantic.Field(alias="ownerId", description="Address ID of the magicast owner"), + ] = None + """ + Address ID of the magicast owner + """ + + cover_image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="coverImageUrl"), + pydantic.Field(alias="coverImageUrl", description="URL for the magicast cover image thumbnail"), + ] = None + """ + URL for the magicast cover image thumbnail + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/magicast_chapter.py b/src/roamhq/types/magicast_chapter.py new file mode 100644 index 0000000..e2e34ad --- /dev/null +++ b/src/roamhq/types/magicast_chapter.py @@ -0,0 +1,39 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class MagicastChapter(UniversalBaseModel): + """ + A navigation chapter generated from the Magicast transcript. + """ + + title: str = pydantic.Field() + """ + Chapter title. + """ + + start_time: typing_extensions.Annotated[ + int, + FieldMetadata(alias="startTime"), + pydantic.Field(alias="startTime", description="Milliseconds from the start of the recording."), + ] + """ + Milliseconds from the start of the recording. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/magicast_cue.py b/src/roamhq/types/magicast_cue.py new file mode 100644 index 0000000..a81d5ac --- /dev/null +++ b/src/roamhq/types/magicast_cue.py @@ -0,0 +1,53 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class MagicastCue(UniversalBaseModel): + """ + One transcribed sentence. Magicast transcripts are not speaker-diarized, + so there is no `speaker` field (unlike meeting `transcript.info` cues). + """ + + text: str = pydantic.Field() + """ + The transcribed text. + """ + + start_offset: typing_extensions.Annotated[ + int, + FieldMetadata(alias="startOffset"), + pydantic.Field( + alias="startOffset", description="Milliseconds from the start of the recording when the sentence began." + ), + ] + """ + Milliseconds from the start of the recording when the sentence began. + """ + + end_offset: typing_extensions.Annotated[ + int, + FieldMetadata(alias="endOffset"), + pydantic.Field( + alias="endOffset", description="Milliseconds from the start of the recording when the sentence ended." + ), + ] + """ + Milliseconds from the start of the recording when the sentence ended. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/magicast_info.py b/src/roamhq/types/magicast_info.py new file mode 100644 index 0000000..0fbbf57 --- /dev/null +++ b/src/roamhq/types/magicast_info.py @@ -0,0 +1,98 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.serialization import FieldMetadata +from .magicast import Magicast +from .magicast_chapter import MagicastChapter +from .magicast_cue import MagicastCue +from .magicast_info_video_status import MagicastInfoVideoStatus + + +class MagicastInfo(Magicast): + duration_ms: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="durationMs"), + pydantic.Field( + alias="durationMs", description="Duration of the playable video in milliseconds. Omitted when unknown." + ), + ] = None + """ + Duration of the playable video in milliseconds. Omitted when unknown. + """ + + video_status: typing_extensions.Annotated[ + typing.Optional[MagicastInfoVideoStatus], + FieldMetadata(alias="videoStatus"), + pydantic.Field( + alias="videoStatus", + description="Where the Magicast video is:\n\n- `none` — no content file. There is nothing to play.\n- `processing` — a content file exists but is not ready yet. Call\n `/magicast.info` again shortly. You can still mint a share link\n with [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link).\n- `available` — the video is ready. `videoUrl` is a short-lived\n signed download URL; fetch it at download time and do not persist\n it as the canonical link.", + ), + ] = None + """ + Where the Magicast video is: + + - `none` — no content file. There is nothing to play. + - `processing` — a content file exists but is not ready yet. Call + `/magicast.info` again shortly. You can still mint a share link + with [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link). + - `available` — the video is ready. `videoUrl` is a short-lived + signed download URL; fetch it at download time and do not persist + it as the canonical link. + """ + + video_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="videoUrl"), + pydantic.Field( + alias="videoUrl", + description="Short-lived signed URL for the video file. Present only when\n`videoStatus` is `available` and the signed URL could be minted.\nDo not persist this URL.", + ), + ] = None + """ + Short-lived signed URL for the video file. Present only when + `videoStatus` is `available` and the signed URL could be minted. + Do not persist this URL. + """ + + share_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="shareUrl"), + pydantic.Field( + alias="shareUrl", + description="Player URL (`https://ro.am/share/{key}`) if a share link already\nexists. Omitted when nobody has minted one. Fetching this endpoint\nnever creates a share link — use\n[`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link) for that.\nThere is no `https://ro.am/magicast/{id}` URL.", + ), + ] = None + """ + Player URL (`https://ro.am/share/{key}`) if a share link already + exists. Omitted when nobody has minted one. Fetching this endpoint + never creates a share link — use + [`/magicast.shareLink`](https://developer.ro.am/docs/api/magicast-share-link) for that. + There is no `https://ro.am/magicast/{id}` URL. + """ + + chapters: typing.Optional[typing.List[MagicastChapter]] = pydantic.Field(default=None) + """ + Navigation chapters. Omitted when none have been generated. + """ + + cues: typing.Optional[typing.List[MagicastCue]] = pydantic.Field(default=None) + """ + Flattened transcript sentences. Omitted while the transcript is still + processing or unavailable. Magicasts are not meetings — there is no + summary or action-items field. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/magicast_info_video_status.py b/src/roamhq/types/magicast_info_video_status.py new file mode 100644 index 0000000..039512c --- /dev/null +++ b/src/roamhq/types/magicast_info_video_status.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +MagicastInfoVideoStatus = typing.Union[typing.Literal["none", "processing", "available"], typing.Any] diff --git a/src/roamhq/types/meeting_participant.py b/src/roamhq/types/meeting_participant.py new file mode 100644 index 0000000..0126f30 --- /dev/null +++ b/src/roamhq/types/meeting_participant.py @@ -0,0 +1,44 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .meeting_participant_type import MeetingParticipantType + + +class MeetingParticipant(UniversalBaseModel): + """ + A participant in a meeting + """ + + type: MeetingParticipantType = pydantic.Field() + """ + Whether the participant is a workspace member or an external guest + """ + + id: str = pydantic.Field() + """ + The participant's address ID + """ + + name: str = pydantic.Field() + """ + Display name of the participant + """ + + email: typing.Optional[str] = pydantic.Field(default=None) + """ + Email address of the participant (requires `user:read.email` scope) + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/meeting_participant_type.py b/src/roamhq/types/meeting_participant_type.py new file mode 100644 index 0000000..9d2da47 --- /dev/null +++ b/src/roamhq/types/meeting_participant_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +MeetingParticipantType = typing.Union[typing.Literal["member", "guest"], typing.Any] diff --git a/src/roamhq/types/reaction.py b/src/roamhq/types/reaction.py new file mode 100644 index 0000000..901cba6 --- /dev/null +++ b/src/roamhq/types/reaction.py @@ -0,0 +1,41 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class Reaction(UniversalBaseModel): + """ + One emoji reaction on a message, grouped across reactors + (Slack-style `{name, count, users}`). + """ + + name: str = pydantic.Field() + """ + Reaction shortcode without surrounding colons (e.g. `thumbs_up`, `wave`, + `heart`). Matches the `name` accepted by `reaction.add` / `reaction.remove` + and delivered on the `chat.reaction` webhook. + """ + + count: int = pydantic.Field() + """ + Number of users who added this reaction. + """ + + users: typing.List[str] = pydantic.Field() + """ + Visible principal IDs of reactors. Hydrate with `user.list?ids`; unauthorized IDs are omitted and `count` reflects this array. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/sender.py b/src/roamhq/types/sender.py new file mode 100644 index 0000000..414c865 --- /dev/null +++ b/src/roamhq/types/sender.py @@ -0,0 +1,66 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class Sender(UniversalBaseModel): + """ + Optional sender customization — see the + [Sender Profiles guide](https://developer.ro.am/docs/guides/sender-profiles). + + `name` / `imageUrl` are **per-message display overrides**: they are stored on + the message itself and never modify your app's (or a persona's) profile. + `id` selects a **configured bot persona** (Roam Administration > Developer > + edit your app > Add Bot Persona) as the message author; an id that doesn't + match a configured persona is accepted and ignored, and the message is + authored by the app's root identity. + + Personal access tokens reject this field (400). + """ + + id: typing.Optional[str] = pydantic.Field(default=None) + """ + Code of a configured bot persona to author the message as (trimmed, + case-insensitive). Omitted, empty, or `_` posts as the app's root + identity. Unconfigured ids are accepted and ignored — supplying an id + never creates a persona. + """ + + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Display name override for this message only (max 128 characters). + Does not rename the app or persona. + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field( + alias="imageUrl", + description='Avatar URL override for this message only. Must be an absolute HTTP(S)\nURL. From API version `2026-08-25` it must be a Roam-hosted avatar URL\nfrom [`/asset.create`](https://developer.ro.am/docs/api/asset-create) with `purpose: "avatar"`\n(the response `imageUrl`), or a legacy `/card-images/` or\n`/photos/people/` URL. Third-party image URLs return 400. Older version\npins still accept any absolute HTTP(S) URL.', + ), + ] = None + """ + Avatar URL override for this message only. Must be an absolute HTTP(S) + URL. From API version `2026-08-25` it must be a Roam-hosted avatar URL + from [`/asset.create`](https://developer.ro.am/docs/api/asset-create) with `purpose: "avatar"` + (the response `imageUrl`), or a legacy `/card-images/` or + `/photos/people/` URL. Third-party image URLs return 400. Older version + pins still accept any absolute HTTP(S) URL. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/unfurl_content.py b/src/roamhq/types/unfurl_content.py new file mode 100644 index 0000000..f376137 --- /dev/null +++ b/src/roamhq/types/unfurl_content.py @@ -0,0 +1,52 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .unfurl_content_image import UnfurlContentImage + + +class UnfurlContent(UniversalBaseModel): + """ + Rich preview content supplied by an app for one exact URL. + """ + + title: str = pydantic.Field() + """ + Title displayed on the preview card. + """ + + description: typing.Optional[str] = pydantic.Field(default=None) + """ + Optional preview summary. + """ + + site_name: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="siteName"), + pydantic.Field(alias="siteName", description="Optional service or website name."), + ] = None + """ + Optional service or website name. + """ + + favicon: typing.Optional[str] = pydantic.Field(default=None) + """ + Optional HTTPS favicon URL. Roam's server does not fetch it. + """ + + image: typing.Optional[UnfurlContentImage] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/unfurl_content_image.py b/src/roamhq/types/unfurl_content_image.py new file mode 100644 index 0000000..09d32a9 --- /dev/null +++ b/src/roamhq/types/unfurl_content_image.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class UnfurlContentImage(UniversalBaseModel): + url: str = pydantic.Field() + """ + HTTPS image URL. Roam's server does not fetch it. + """ + + type: typing.Optional[str] = pydantic.Field(default=None) + """ + Image media type, such as `image/png`. + """ + + width: typing.Optional[int] = None + height: typing.Optional[int] = None + alt: typing.Optional[str] = pydantic.Field(default=None) + """ + Accessible alternative text for the image. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/user.py b/src/roamhq/types/user.py new file mode 100644 index 0000000..8bb1b12 --- /dev/null +++ b/src/roamhq/types/user.py @@ -0,0 +1,138 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .user_status import UserStatus +from .user_type import UserType +from .user_will_return import UserWillReturn + + +class User(UniversalBaseModel): + """ + A v1 acting principal. Workspace members and guests have `type: user`; + classic bots, agents, assistants, and coworkers have `type: bot`. + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + """ + + id: str = pydantic.Field() + """ + The principal's unique address identifier. + """ + + type: UserType = pydantic.Field() + """ + Stable public principal type. All automated actors are `bot`. + """ + + name: str = pydantic.Field() + """ + Display name of the principal. + """ + + image_url: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="imageUrl"), + pydantic.Field(alias="imageUrl", description="URL of the principal's profile image."), + ] = None + """ + URL of the principal's profile image. + """ + + email: typing.Optional[str] = pydantic.Field(default=None) + """ + Email address for a member or guest (requires `user:read.email`). Omitted for bots. + """ + + is_guest: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="isGuest"), + pydantic.Field( + alias="isGuest", description="Present and true only for users without membership in the caller's account." + ), + ] = None + """ + Present and true only for users without membership in the caller's account. + """ + + is_admin: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="isAdmin"), + pydantic.Field( + alias="isAdmin", + description="Whether a workspace member is an admin. Present for members even when false; omitted for guests and bots.", + ), + ] = None + """ + Whether a workspace member is an admin. Present for members even when false; omitted for guests and bots. + """ + + job_title: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="jobTitle"), + pydantic.Field(alias="jobTitle", description="Workspace member's job title. Omitted for guests and bots."), + ] = None + """ + Workspace member's job title. Omitted for guests and bots. + """ + + location: typing.Optional[str] = pydantic.Field(default=None) + """ + Workspace member's location. Omitted for guests and bots. + """ + + status: typing.Optional[UserStatus] = pydantic.Field(default=None) + """ + User's current presence status. Only included when `expand=status` is requested and the `user:read.status` scope is granted. + """ + + will_return: typing_extensions.Annotated[ + typing.Optional[UserWillReturn], + FieldMetadata(alias="willReturn"), + pydantic.Field( + alias="willReturn", + description='Out-of-office / "Will Return" status. Present only when `expand=status` is requested, the `user:read.status` scope is granted, and the user has a future return time. A user can be `checkedIn` and still have `willReturn` (multi-day Out of Roam) — key off the presence of this object rather than `status` alone.', + ), + ] = None + """ + Out-of-office / "Will Return" status. Present only when `expand=status` is requested, the `user:read.status` scope is granted, and the user has a future return time. A user can be `checkedIn` and still have `willReturn` (multi-day Out of Roam) — key off the presence of this object rather than `status` alone. + """ + + available: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether the user is currently available for visitors. Only included when `expand=available` is requested and the `user:read.status` scope is granted. + """ + + bot_code: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="botCode"), + pydantic.Field(alias="botCode", description="Classic bot persona identifier, when available."), + ] = None + """ + Classic bot persona identifier, when available. + """ + + integration_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="integrationId"), + pydantic.Field( + alias="integrationId", description="Integration/client identifier for an automated actor, when available." + ), + ] = None + """ + Integration/client identifier for an automated actor, when available. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/user_activity.py b/src/roamhq/types/user_activity.py new file mode 100644 index 0000000..f23b622 --- /dev/null +++ b/src/roamhq/types/user_activity.py @@ -0,0 +1,81 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .user_activity_display import UserActivityDisplay + + +class UserActivity(UniversalBaseModel): + """ + One live external activity for a user. Returned by `user.activity.set` and + as each entry in `user.activity.list`. + """ + + user_id: typing_extensions.Annotated[ + str, FieldMetadata(alias="userId"), pydantic.Field(alias="userId", description="Bare UUID of the target user.") + ] + """ + Bare UUID of the target user. + """ + + external_id: typing_extensions.Annotated[ + str, + FieldMetadata(alias="externalId"), + pydantic.Field( + alias="externalId", + description="Caller-chosen id for this session, unique per integration and user\n(for example `justcall:call:CA123`). At most 128 Unicode code points.", + ), + ] + """ + Caller-chosen id for this session, unique per integration and user + (for example `justcall:call:CA123`). At most 128 Unicode code points. + """ + + display: UserActivityDisplay + dnd: bool = pydantic.Field() + """ + Whether this activity currently contributes Do Not Disturb on the + user's assigned office. + """ + + started_at: typing_extensions.Annotated[ + dt.datetime, + FieldMetadata(alias="startedAt"), + pydantic.Field( + alias="startedAt", + description="When this session started (RFC3339). A heartbeat that omits\n`startedAt` keeps the original value.", + ), + ] + """ + When this session started (RFC3339). A heartbeat that omits + `startedAt` keeps the original value. + """ + + expires_at: typing_extensions.Annotated[ + dt.datetime, + FieldMetadata(alias="expiresAt"), + pydantic.Field( + alias="expiresAt", + description="Server-stamped expiry (RFC3339). The indicator vanishes from the map\nand from `.list` once this instant has passed.", + ), + ] + """ + Server-stamped expiry (RFC3339). The indicator vanishes from the map + and from `.list` once this instant has passed. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/user_activity_display.py b/src/roamhq/types/user_activity_display.py new file mode 100644 index 0000000..202489b --- /dev/null +++ b/src/roamhq/types/user_activity_display.py @@ -0,0 +1,51 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .user_activity_display_color import UserActivityDisplayColor + + +class UserActivityDisplay(UniversalBaseModel): + """ + What the map renders for this activity: a seat badge, hover tooltip, and + optional glow. Limits count Unicode code points (runes), not bytes. + """ + + emoji: str = pydantic.Field() + """ + Badge shown on the user's seat. Required. At most 16 Unicode code + points, so ZWJ sequences (family emoji, flags) stay valid. + """ + + title: str = pydantic.Field() + """ + Required hover-tooltip title. At most 140 Unicode code points. + """ + + subtitle: typing.Optional[str] = pydantic.Field(default=None) + """ + Optional supporting line after the title (for example the source app + and a customer name). At most 140 Unicode code points. Omitted when + empty. + """ + + color: typing.Optional[UserActivityDisplayColor] = pydantic.Field(default=None) + """ + Curated glow palette name. Omit (or send empty) for a quiet badge-only + activity. Unknown names are rejected at set time. Clients resolve the + name to light/dark hex — hex is not part of the API. First-party agent + glows (Claude, Codex, Pi, room agents) are not in this palette. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/user_activity_display_color.py b/src/roamhq/types/user_activity_display_color.py new file mode 100644 index 0000000..c06347f --- /dev/null +++ b/src/roamhq/types/user_activity_display_color.py @@ -0,0 +1,12 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +UserActivityDisplayColor = typing.Union[ + typing.Literal[ + "blue", "gold", "gray", "green", "indigo", "lime", "orange", "pink", "purple", "red", "teal", "yellow" + ], + typing.Any, +] diff --git a/src/roamhq/types/user_audit_log.py b/src/roamhq/types/user_audit_log.py new file mode 100644 index 0000000..45b5271 --- /dev/null +++ b/src/roamhq/types/user_audit_log.py @@ -0,0 +1,57 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .user_audit_log_platform import UserAuditLogPlatform + + +class UserAuditLog(UniversalBaseModel): + timestamp: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + Time at which the audit log entry occurred (UTC) + """ + + event_type: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="eventType"), + pydantic.Field(alias="eventType", description="Type of the audit event"), + ] = None + """ + Type of the audit event + """ + + name: typing.Optional[str] = pydantic.Field(default=None) + """ + Name of the user associated with this event + """ + + email: typing.Optional[str] = pydantic.Field(default=None) + """ + Email address of the user associated with this event + """ + + data: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(default=None) + """ + JSON payload containing event type specific data, such as chat recipients or which room was knocked on + """ + + platform: typing.Optional[UserAuditLogPlatform] = pydantic.Field(default=None) + """ + Best-effort client platform the user was on for this event. Reliably present for ENTER_ROAM events; may be absent for some events. Maps to usage categories as: mobile = ios/android, desktop = electron, web = web. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/user_audit_log_platform.py b/src/roamhq/types/user_audit_log_platform.py new file mode 100644 index 0000000..b1ad338 --- /dev/null +++ b/src/roamhq/types/user_audit_log_platform.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +UserAuditLogPlatform = typing.Union[typing.Literal["web", "electron", "ios", "android", "sip", "bot"], typing.Any] diff --git a/src/roamhq/types/user_status.py b/src/roamhq/types/user_status.py new file mode 100644 index 0000000..3a6fd5c --- /dev/null +++ b/src/roamhq/types/user_status.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +UserStatus = typing.Union[typing.Literal["checkedIn", "checkedOut"], typing.Any] diff --git a/src/roamhq/types/user_type.py b/src/roamhq/types/user_type.py new file mode 100644 index 0000000..a81bbf9 --- /dev/null +++ b/src/roamhq/types/user_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +UserType = typing.Union[typing.Literal["user", "bot"], typing.Any] diff --git a/src/roamhq/types/user_will_return.py b/src/roamhq/types/user_will_return.py new file mode 100644 index 0000000..24bc62b --- /dev/null +++ b/src/roamhq/types/user_will_return.py @@ -0,0 +1,52 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata + + +class UserWillReturn(UniversalBaseModel): + """ + Out-of-office / "Will Return" status. Present only when `expand=status` is requested, the `user:read.status` scope is granted, and the user has a future return time. A user can be `checkedIn` and still have `willReturn` (multi-day Out of Roam) — key off the presence of this object rather than `status` alone. + """ + + return_time: typing_extensions.Annotated[ + dt.datetime, + FieldMetadata(alias="returnTime"), + pydantic.Field(alias="returnTime", description="When the user is expected to return (RFC 3339)."), + ] + """ + When the user is expected to return (RFC 3339). + """ + + reason: typing.Optional[str] = pydantic.Field(default=None) + """ + Optional absence message (e.g. "On Vacation"). + """ + + out_of_roam: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="outOfRoam"), + pydantic.Field( + alias="outOfRoam", + description="When true, multi-day Out of Roam that persists across check-ins. When false or omitted, same-day Will Return Today.", + ), + ] = None + """ + When true, multi-day Out of Roam that persists across check-ins. When false or omitted, same-day Will Return Today. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/webhook.py b/src/roamhq/types/webhook.py new file mode 100644 index 0000000..4695410 --- /dev/null +++ b/src/roamhq/types/webhook.py @@ -0,0 +1,96 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .webhook_event import WebhookEvent +from .webhook_subscription_filter import WebhookSubscriptionFilter + + +class Webhook(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique identifier of the webhook subscription. + """ + + event: WebhookEvent = pydantic.Field() + """ + Subscribed event name. + """ + + url: str = pydantic.Field() + """ + Destination URL for webhook deliveries. + """ + + filter: typing.Optional[WebhookSubscriptionFilter] = pydantic.Field(default=None) + """ + Event-specific filter applied to the subscription. + """ + + dynamic: bool = pydantic.Field() + """ + `true` if the subscription was created via `/webhook.subscribe`. + `false` if it was configured statically in the Roam Administration UI. + """ + + created: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + When the subscription was created. + """ + + last_success_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="lastSuccessAt"), + pydantic.Field( + alias="lastSuccessAt", + description="Last terminal 2xx (RFC3339 UTC). Omitted until the destination has\nsucceeded at least once. Kept when a pause is cleared.", + ), + ] = None + """ + Last terminal 2xx (RFC3339 UTC). Omitted until the destination has + succeeded at least once. Kept when a pause is cleared. + """ + + fail_streak_started_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="failStreakStartedAt"), + pydantic.Field( + alias="failStreakStartedAt", + description="Start of the current consecutive-failure span (RFC3339 UTC). Cleared\non 2xx and on [`/webhook.subscribe`](https://developer.ro.am/docs/webhooks/webhook-subscribe)\nto the same event+URL. Omitted when healthy.", + ), + ] = None + """ + Start of the current consecutive-failure span (RFC3339 UTC). Cleared + on 2xx and on [`/webhook.subscribe`](https://developer.ro.am/docs/webhooks/webhook-subscribe) + to the same event+URL. Omitted when healthy. + """ + + disabled_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="disabledAt"), + pydantic.Field( + alias="disabledAt", + description="When the fail streak reached 24 hours (RFC3339 UTC). While set the\nsubscription is paused (one probe event per day). Omitted when active.\nSee [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health).", + ), + ] = None + """ + When the fail streak reached 24 hours (RFC3339 UTC). While set the + subscription is paused (one probe event per day). Omitted when active. + See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/webhook_event.py b/src/roamhq/types/webhook_event.py new file mode 100644 index 0000000..939b297 --- /dev/null +++ b/src/roamhq/types/webhook_event.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +WebhookEvent = typing.Union[ + typing.Literal[ + "chat.message", + "chat.reaction", + "chat.link.shared", + "lobby.booked", + "magicast.created", + "meeting.started", + "meeting.ended", + "user.status.update", + "onair.event.created", + "onair.event.updated", + "onair.event.canceled", + "onair.guest.rsvp", + "onair.guest.added", + "token.revoked", + "app.uninstalled", + ], + typing.Any, +] diff --git a/src/roamhq/types/webhook_subscription_filter.py b/src/roamhq/types/webhook_subscription_filter.py new file mode 100644 index 0000000..e47522c --- /dev/null +++ b/src/roamhq/types/webhook_subscription_filter.py @@ -0,0 +1,78 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ..core.serialization import FieldMetadata +from .webhook_subscription_filter_chat_type import WebhookSubscriptionFilterChatType +from .webhook_subscription_filter_status import WebhookSubscriptionFilterStatus + + +class WebhookSubscriptionFilter(UniversalBaseModel): + """ + Event-specific filter to limit webhook notifications. Different properties apply to different events. + """ + + chat_type: typing_extensions.Annotated[ + typing.Optional[WebhookSubscriptionFilterChatType], + FieldMetadata(alias="chatType"), + pydantic.Field( + alias="chatType", + description="For `chat.message`: restrict to direct messages (`dm`) or group messages (`group`).", + ), + ] = None + """ + For `chat.message`: restrict to direct messages (`dm`) or group messages (`group`). + """ + + mention: typing.Optional[bool] = pydantic.Field(default=None) + """ + For `chat.message`: restrict to messages that @mention your app. + """ + + names: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + For `chat.reaction`: restrict to events where the changed reaction is one of these names (e.g. 'thumbs_up', 'heart'), matching the `name` field of `/reaction.add` and `/reaction.list`. + """ + + has_video: typing_extensions.Annotated[ + typing.Optional[bool], + FieldMetadata(alias="hasVideo"), + pydantic.Field( + alias="hasVideo", + description='For `meeting.ended`: restrict to meetings that were video recorded, i.e. a video track exists. This keys on "was recorded", not "is ready to fetch" — the recording upload is a separate pipeline that has almost never finished when the event fires, so a matching delivery normally arrives while the upload is still in flight. Call `/meeting.info` and read `videoStatus` to learn when the recording is playable. Only `true` is accepted — `{"hasVideo": false}` is rejected at subscribe time rather than silently treated as no filter, so omit the filter to receive every `meeting.ended` event.', + ), + ] = None + """ + For `meeting.ended`: restrict to meetings that were video recorded, i.e. a video track exists. This keys on "was recorded", not "is ready to fetch" — the recording upload is a separate pipeline that has almost never finished when the event fires, so a matching delivery normally arrives while the upload is still in flight. Call `/meeting.info` and read `videoStatus` to learn when the recording is playable. Only `true` is accepted — `{"hasVideo": false}` is rejected at subscribe time rather than silently treated as no filter, so omit the filter to receive every `meeting.ended` event. + """ + + event_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="eventId"), + pydantic.Field( + alias="eventId", + description="For On-Air events (`onair.event.*`, `onair.guest.*`): restrict to the specified event.", + ), + ] = None + """ + For On-Air events (`onair.event.*`, `onair.guest.*`): restrict to the specified event. + """ + + status: typing.Optional[WebhookSubscriptionFilterStatus] = pydantic.Field(default=None) + """ + For `onair.guest.rsvp`: restrict to the specified RSVP status. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/types/webhook_subscription_filter_chat_type.py b/src/roamhq/types/webhook_subscription_filter_chat_type.py new file mode 100644 index 0000000..56088d0 --- /dev/null +++ b/src/roamhq/types/webhook_subscription_filter_chat_type.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +WebhookSubscriptionFilterChatType = typing.Union[typing.Literal["dm", "group"], typing.Any] diff --git a/src/roamhq/types/webhook_subscription_filter_status.py b/src/roamhq/types/webhook_subscription_filter_status.py new file mode 100644 index 0000000..dc98743 --- /dev/null +++ b/src/roamhq/types/webhook_subscription_filter_status.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +WebhookSubscriptionFilterStatus = typing.Union[typing.Literal["invited", "going", "maybe", "notGoing"], typing.Any] diff --git a/src/roamhq/user/__init__.py b/src/roamhq/user/__init__.py new file mode 100644 index 0000000..aeeca02 --- /dev/null +++ b/src/roamhq/user/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ListUserResponse +_dynamic_imports: typing.Dict[str, str] = {"ListUserResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListUserResponse"] diff --git a/src/roamhq/user/client.py b/src/roamhq/user/client.py new file mode 100644 index 0000000..2570dbc --- /dev/null +++ b/src/roamhq/user/client.py @@ -0,0 +1,335 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.user import User +from .raw_client import AsyncRawUserClient, RawUserClient +from .types.list_user_response import ListUserResponse + + +class UserClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawUserClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawUserClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawUserClient + """ + return self._raw_client + + def list( + self, + *, + ids: typing.Optional[str] = None, + q: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListUserResponse: + """ + List workspace members, or hydrate an explicit ordered set of principal IDs. + + Without `ids`, this is the active workspace member directory: guests, + bots, and archived/deactivated members are never enumerated. Members are + returned in the order they were added to the account. + + With `ids`, the endpoint becomes an unpaginated principal hydrator. Pass one + comma-separated value containing at most 100 bare or tagged IDs. Duplicate + tokens are deduplicated in first-seen order; resolved entries are returned + in that order. Unknown IDs, groups, and unauthorized principals are silently + omitted. Explicit lookup may resolve archived/deactivated users and + authorized automated actors. The response keeps the existing `users` key + but its entries are principals, and `nextCursor` is omitted. + + `ids` cannot be combined with `q`, `limit`, or `cursor`. `expand=status` + remains supported in either mode. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + **Required scope:** `user:read` (add `user:read.email` to include email addresses, `user:read.status` to expand presence status and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + ids : typing.Optional[str] + One comma-separated list of up to 100 bare or tagged principal IDs. + Repeating the `ids` query parameter, including empty tokens, or combining + it with `q`, `limit`, or `cursor` returns `invalid_parameter`. + + q : typing.Optional[str] + Case-insensitive member-directory filter by name. Also matches email + when the token has `user:read.email`. Cannot be combined with `ids`. + + limit : typing.Optional[int] + The number of directory members to return per response. Default is 10. Cannot be combined with `ids`. + + cursor : typing.Optional[str] + Opaque directory cursor from a previous response's `nextCursor`. Cannot be combined with `ids`. + + expand : typing.Optional[str] + Comma-separated list of additional fields. Supported: `status` (requires `user:read.status`). Expanding `status` also returns `willReturn` when set. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListUserResponse + Directory members or explicitly hydrated principals retrieved successfully. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.user.list() + """ + _response = self._raw_client.list( + ids=ids, q=q, limit=limit, cursor=cursor, expand=expand, request_options=request_options + ) + return _response.data + + def info( + self, + *, + id: typing.Optional[str] = None, + email: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> User: + """ + Resolve a v1 principal by ID, or look up a workspace member by email. + + ID lookup resolves active or archived members, guests, and authorized + automated actors (classic bots, agents, assistants, and coworkers). The + response always includes `type: "user" | "bot"`; guests additionally have + `isGuest: true`. Groups, unknown IDs, and automated actors outside the + caller's Roam/account/owner boundary return `user_not_found`. + + Email lookup remains workspace-member-only. Personal access tokens and the + MCP `user_info` tool may use ID lookup. + + Provide either `id` or `email`, not both. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals) for the + taxonomy, visibility rules, and directory-versus-hydration guidance. + + **Required scope:** `user:read` (add `user:read.email` to look up by email or include email in response, `user:read.status` to expand presence status, availability, and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + id : typing.Optional[str] + A bare or tagged principal ID. Mutually exclusive with `email`. + + email : typing.Optional[str] + The user's email address. Mutually exclusive with `id`. Requires `user:read.email` scope. + + expand : typing.Optional[str] + Comma-separated list of additional fields to include. Supported: `status`, `available` (each requires `user:read.status`). Expanding `status` also returns `willReturn` when the user has a future out-of-office entry. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + User + Principal info retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.user.info() + """ + _response = self._raw_client.info(id=id, email=email, expand=expand, request_options=request_options) + return _response.data + + +class AsyncUserClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawUserClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawUserClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawUserClient + """ + return self._raw_client + + async def list( + self, + *, + ids: typing.Optional[str] = None, + q: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ListUserResponse: + """ + List workspace members, or hydrate an explicit ordered set of principal IDs. + + Without `ids`, this is the active workspace member directory: guests, + bots, and archived/deactivated members are never enumerated. Members are + returned in the order they were added to the account. + + With `ids`, the endpoint becomes an unpaginated principal hydrator. Pass one + comma-separated value containing at most 100 bare or tagged IDs. Duplicate + tokens are deduplicated in first-seen order; resolved entries are returned + in that order. Unknown IDs, groups, and unauthorized principals are silently + omitted. Explicit lookup may resolve archived/deactivated users and + authorized automated actors. The response keeps the existing `users` key + but its entries are principals, and `nextCursor` is omitted. + + `ids` cannot be combined with `q`, `limit`, or `cursor`. `expand=status` + remains supported in either mode. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + **Required scope:** `user:read` (add `user:read.email` to include email addresses, `user:read.status` to expand presence status and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + ids : typing.Optional[str] + One comma-separated list of up to 100 bare or tagged principal IDs. + Repeating the `ids` query parameter, including empty tokens, or combining + it with `q`, `limit`, or `cursor` returns `invalid_parameter`. + + q : typing.Optional[str] + Case-insensitive member-directory filter by name. Also matches email + when the token has `user:read.email`. Cannot be combined with `ids`. + + limit : typing.Optional[int] + The number of directory members to return per response. Default is 10. Cannot be combined with `ids`. + + cursor : typing.Optional[str] + Opaque directory cursor from a previous response's `nextCursor`. Cannot be combined with `ids`. + + expand : typing.Optional[str] + Comma-separated list of additional fields. Supported: `status` (requires `user:read.status`). Expanding `status` also returns `willReturn` when set. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListUserResponse + Directory members or explicitly hydrated principals retrieved successfully. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.user.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list( + ids=ids, q=q, limit=limit, cursor=cursor, expand=expand, request_options=request_options + ) + return _response.data + + async def info( + self, + *, + id: typing.Optional[str] = None, + email: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> User: + """ + Resolve a v1 principal by ID, or look up a workspace member by email. + + ID lookup resolves active or archived members, guests, and authorized + automated actors (classic bots, agents, assistants, and coworkers). The + response always includes `type: "user" | "bot"`; guests additionally have + `isGuest: true`. Groups, unknown IDs, and automated actors outside the + caller's Roam/account/owner boundary return `user_not_found`. + + Email lookup remains workspace-member-only. Personal access tokens and the + MCP `user_info` tool may use ID lookup. + + Provide either `id` or `email`, not both. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals) for the + taxonomy, visibility rules, and directory-versus-hydration guidance. + + **Required scope:** `user:read` (add `user:read.email` to look up by email or include email in response, `user:read.status` to expand presence status, availability, and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + id : typing.Optional[str] + A bare or tagged principal ID. Mutually exclusive with `email`. + + email : typing.Optional[str] + The user's email address. Mutually exclusive with `id`. Requires `user:read.email` scope. + + expand : typing.Optional[str] + Comma-separated list of additional fields to include. Supported: `status`, `available` (each requires `user:read.status`). Expanding `status` also returns `willReturn` when the user has a future out-of-office entry. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + User + Principal info retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.user.info() + + + asyncio.run(main()) + """ + _response = await self._raw_client.info(id=id, email=email, expand=expand, request_options=request_options) + return _response.data diff --git a/src/roamhq/user/raw_client.py b/src/roamhq/user/raw_client.py new file mode 100644 index 0000000..3c7d684 --- /dev/null +++ b/src/roamhq/user/raw_client.py @@ -0,0 +1,638 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from ..types.user import User +from .types.list_user_response import ListUserResponse +from pydantic import ValidationError + + +class RawUserClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, + *, + ids: typing.Optional[str] = None, + q: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ListUserResponse]: + """ + List workspace members, or hydrate an explicit ordered set of principal IDs. + + Without `ids`, this is the active workspace member directory: guests, + bots, and archived/deactivated members are never enumerated. Members are + returned in the order they were added to the account. + + With `ids`, the endpoint becomes an unpaginated principal hydrator. Pass one + comma-separated value containing at most 100 bare or tagged IDs. Duplicate + tokens are deduplicated in first-seen order; resolved entries are returned + in that order. Unknown IDs, groups, and unauthorized principals are silently + omitted. Explicit lookup may resolve archived/deactivated users and + authorized automated actors. The response keeps the existing `users` key + but its entries are principals, and `nextCursor` is omitted. + + `ids` cannot be combined with `q`, `limit`, or `cursor`. `expand=status` + remains supported in either mode. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + **Required scope:** `user:read` (add `user:read.email` to include email addresses, `user:read.status` to expand presence status and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + ids : typing.Optional[str] + One comma-separated list of up to 100 bare or tagged principal IDs. + Repeating the `ids` query parameter, including empty tokens, or combining + it with `q`, `limit`, or `cursor` returns `invalid_parameter`. + + q : typing.Optional[str] + Case-insensitive member-directory filter by name. Also matches email + when the token has `user:read.email`. Cannot be combined with `ids`. + + limit : typing.Optional[int] + The number of directory members to return per response. Default is 10. Cannot be combined with `ids`. + + cursor : typing.Optional[str] + Opaque directory cursor from a previous response's `nextCursor`. Cannot be combined with `ids`. + + expand : typing.Optional[str] + Comma-separated list of additional fields. Supported: `status` (requires `user:read.status`). Expanding `status` also returns `willReturn` when set. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListUserResponse] + Directory members or explicitly hydrated principals retrieved successfully. + """ + _response = self._client_wrapper.httpx_client.request( + "user.list", + method="GET", + params={ + "ids": ids, + "q": q, + "limit": limit, + "cursor": cursor, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListUserResponse, + parse_obj_as( + type_=ListUserResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def info( + self, + *, + id: typing.Optional[str] = None, + email: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[User]: + """ + Resolve a v1 principal by ID, or look up a workspace member by email. + + ID lookup resolves active or archived members, guests, and authorized + automated actors (classic bots, agents, assistants, and coworkers). The + response always includes `type: "user" | "bot"`; guests additionally have + `isGuest: true`. Groups, unknown IDs, and automated actors outside the + caller's Roam/account/owner boundary return `user_not_found`. + + Email lookup remains workspace-member-only. Personal access tokens and the + MCP `user_info` tool may use ID lookup. + + Provide either `id` or `email`, not both. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals) for the + taxonomy, visibility rules, and directory-versus-hydration guidance. + + **Required scope:** `user:read` (add `user:read.email` to look up by email or include email in response, `user:read.status` to expand presence status, availability, and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + id : typing.Optional[str] + A bare or tagged principal ID. Mutually exclusive with `email`. + + email : typing.Optional[str] + The user's email address. Mutually exclusive with `id`. Requires `user:read.email` scope. + + expand : typing.Optional[str] + Comma-separated list of additional fields to include. Supported: `status`, `available` (each requires `user:read.status`). Expanding `status` also returns `willReturn` when the user has a future out-of-office entry. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[User] + Principal info retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "user.info", + method="GET", + params={ + "id": id, + "email": email, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + User, + parse_obj_as( + type_=User, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawUserClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, + *, + ids: typing.Optional[str] = None, + q: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ListUserResponse]: + """ + List workspace members, or hydrate an explicit ordered set of principal IDs. + + Without `ids`, this is the active workspace member directory: guests, + bots, and archived/deactivated members are never enumerated. Members are + returned in the order they were added to the account. + + With `ids`, the endpoint becomes an unpaginated principal hydrator. Pass one + comma-separated value containing at most 100 bare or tagged IDs. Duplicate + tokens are deduplicated in first-seen order; resolved entries are returned + in that order. Unknown IDs, groups, and unauthorized principals are silently + omitted. Explicit lookup may resolve archived/deactivated users and + authorized automated actors. The response keeps the existing `users` key + but its entries are principals, and `nextCursor` is omitted. + + `ids` cannot be combined with `q`, `limit`, or `cursor`. `expand=status` + remains supported in either mode. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals). + + **Required scope:** `user:read` (add `user:read.email` to include email addresses, `user:read.status` to expand presence status and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + ids : typing.Optional[str] + One comma-separated list of up to 100 bare or tagged principal IDs. + Repeating the `ids` query parameter, including empty tokens, or combining + it with `q`, `limit`, or `cursor` returns `invalid_parameter`. + + q : typing.Optional[str] + Case-insensitive member-directory filter by name. Also matches email + when the token has `user:read.email`. Cannot be combined with `ids`. + + limit : typing.Optional[int] + The number of directory members to return per response. Default is 10. Cannot be combined with `ids`. + + cursor : typing.Optional[str] + Opaque directory cursor from a previous response's `nextCursor`. Cannot be combined with `ids`. + + expand : typing.Optional[str] + Comma-separated list of additional fields. Supported: `status` (requires `user:read.status`). Expanding `status` also returns `willReturn` when set. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListUserResponse] + Directory members or explicitly hydrated principals retrieved successfully. + """ + _response = await self._client_wrapper.httpx_client.request( + "user.list", + method="GET", + params={ + "ids": ids, + "q": q, + "limit": limit, + "cursor": cursor, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListUserResponse, + parse_obj_as( + type_=ListUserResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def info( + self, + *, + id: typing.Optional[str] = None, + email: typing.Optional[str] = None, + expand: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[User]: + """ + Resolve a v1 principal by ID, or look up a workspace member by email. + + ID lookup resolves active or archived members, guests, and authorized + automated actors (classic bots, agents, assistants, and coworkers). The + response always includes `type: "user" | "bot"`; guests additionally have + `isGuest: true`. Groups, unknown IDs, and automated actors outside the + caller's Roam/account/owner boundary return `user_not_found`. + + Email lookup remains workspace-member-only. Personal access tokens and the + MCP `user_info` tool may use ID lookup. + + Provide either `id` or `email`, not both. + + See [Identity & Principals](https://developer.ro.am/docs/guides/identity-and-principals) for the + taxonomy, visibility rules, and directory-versus-hydration guidance. + + **Required scope:** `user:read` (add `user:read.email` to look up by email or include email in response, `user:read.status` to expand presence status, availability, and `willReturn`) + + **Access:** Organization and Personal. + + Parameters + ---------- + id : typing.Optional[str] + A bare or tagged principal ID. Mutually exclusive with `email`. + + email : typing.Optional[str] + The user's email address. Mutually exclusive with `id`. Requires `user:read.email` scope. + + expand : typing.Optional[str] + Comma-separated list of additional fields to include. Supported: `status`, `available` (each requires `user:read.status`). Expanding `status` also returns `willReturn` when the user has a future out-of-office entry. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[User] + Principal info retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "user.info", + method="GET", + params={ + "id": id, + "email": email, + "expand": expand, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + User, + parse_obj_as( + type_=User, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/user/types/__init__.py b/src/roamhq/user/types/__init__.py new file mode 100644 index 0000000..f34e5fe --- /dev/null +++ b/src/roamhq/user/types/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .list_user_response import ListUserResponse +_dynamic_imports: typing.Dict[str, str] = {"ListUserResponse": ".list_user_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListUserResponse"] diff --git a/src/roamhq/user/types/list_user_response.py b/src/roamhq/user/types/list_user_response.py new file mode 100644 index 0000000..d8c262f --- /dev/null +++ b/src/roamhq/user/types/list_user_response.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.user import User + + +class ListUserResponse(UniversalBaseModel): + users: typing.Optional[typing.List[User]] = pydantic.Field(default=None) + """ + Principal entries. In directory mode every entry is an active workspace member. + """ + + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Pagination cursor for fetching the next page of results"), + ] = None + """ + Pagination cursor for fetching the next page of results + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/user_audit_log/__init__.py b/src/roamhq/user_audit_log/__init__.py new file mode 100644 index 0000000..f52265f --- /dev/null +++ b/src/roamhq/user_audit_log/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ListUserAuditLogResponse +_dynamic_imports: typing.Dict[str, str] = {"ListUserAuditLogResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListUserAuditLogResponse"] diff --git a/src/roamhq/user_audit_log/client.py b/src/roamhq/user_audit_log/client.py new file mode 100644 index 0000000..8bff3c7 --- /dev/null +++ b/src/roamhq/user_audit_log/client.py @@ -0,0 +1,118 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from .raw_client import AsyncRawUserAuditLogClient, RawUserAuditLogClient +from .types.list_user_audit_log_response import ListUserAuditLogResponse + + +class UserAuditLogClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawUserAuditLogClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawUserAuditLogClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawUserAuditLogClient + """ + return self._raw_client + + def list( + self, *, date: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> ListUserAuditLogResponse: + """ + Get a list of user audit log entries for the account. + + **Required scope:** `userauditlog:read` + + Parameters + ---------- + date : typing.Optional[str] + The date to pull audit log entries from. All activities from that date in UTC are returned. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListUserAuditLogResponse + Audit log entries retrieved successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.user_audit_log.list() + """ + _response = self._raw_client.list(date=date, request_options=request_options) + return _response.data + + +class AsyncUserAuditLogClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawUserAuditLogClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawUserAuditLogClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawUserAuditLogClient + """ + return self._raw_client + + async def list( + self, *, date: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> ListUserAuditLogResponse: + """ + Get a list of user audit log entries for the account. + + **Required scope:** `userauditlog:read` + + Parameters + ---------- + date : typing.Optional[str] + The date to pull audit log entries from. All activities from that date in UTC are returned. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListUserAuditLogResponse + Audit log entries retrieved successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.user_audit_log.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list(date=date, request_options=request_options) + return _response.data diff --git a/src/roamhq/user_audit_log/raw_client.py b/src/roamhq/user_audit_log/raw_client.py new file mode 100644 index 0000000..ecdaa55 --- /dev/null +++ b/src/roamhq/user_audit_log/raw_client.py @@ -0,0 +1,214 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from .types.list_user_audit_log_response import ListUserAuditLogResponse +from pydantic import ValidationError + + +class RawUserAuditLogClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list( + self, *, date: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ListUserAuditLogResponse]: + """ + Get a list of user audit log entries for the account. + + **Required scope:** `userauditlog:read` + + Parameters + ---------- + date : typing.Optional[str] + The date to pull audit log entries from. All activities from that date in UTC are returned. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListUserAuditLogResponse] + Audit log entries retrieved successfully + """ + _response = self._client_wrapper.httpx_client.request( + "userauditlog.list", + method="GET", + params={ + "date": date, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListUserAuditLogResponse, + parse_obj_as( + type_=ListUserAuditLogResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawUserAuditLogClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, *, date: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ListUserAuditLogResponse]: + """ + Get a list of user audit log entries for the account. + + **Required scope:** `userauditlog:read` + + Parameters + ---------- + date : typing.Optional[str] + The date to pull audit log entries from. All activities from that date in UTC are returned. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListUserAuditLogResponse] + Audit log entries retrieved successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "userauditlog.list", + method="GET", + params={ + "date": date, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListUserAuditLogResponse, + parse_obj_as( + type_=ListUserAuditLogResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/user_audit_log/types/__init__.py b/src/roamhq/user_audit_log/types/__init__.py new file mode 100644 index 0000000..75c6b98 --- /dev/null +++ b/src/roamhq/user_audit_log/types/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .list_user_audit_log_response import ListUserAuditLogResponse +_dynamic_imports: typing.Dict[str, str] = {"ListUserAuditLogResponse": ".list_user_audit_log_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["ListUserAuditLogResponse"] diff --git a/src/roamhq/user_audit_log/types/list_user_audit_log_response.py b/src/roamhq/user_audit_log/types/list_user_audit_log_response.py new file mode 100644 index 0000000..fc470cf --- /dev/null +++ b/src/roamhq/user_audit_log/types/list_user_audit_log_response.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.user_audit_log import UserAuditLog + + +class ListUserAuditLogResponse(UniversalBaseModel): + audit_logs: typing_extensions.Annotated[ + typing.Optional[typing.List[UserAuditLog]], FieldMetadata(alias="auditLogs"), pydantic.Field(alias="auditLogs") + ] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/users/__init__.py b/src/roamhq/users/__init__.py new file mode 100644 index 0000000..5fbb2a7 --- /dev/null +++ b/src/roamhq/users/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import UserActivityListResponse +_dynamic_imports: typing.Dict[str, str] = {"UserActivityListResponse": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["UserActivityListResponse"] diff --git a/src/roamhq/users/client.py b/src/roamhq/users/client.py new file mode 100644 index 0000000..b39356a --- /dev/null +++ b/src/roamhq/users/client.py @@ -0,0 +1,740 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.user_activity import UserActivity +from ..types.user_activity_display import UserActivityDisplay +from .raw_client import AsyncRawUsersClient, RawUsersClient +from .types.user_activity_list_response import UserActivityListResponse + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class UsersClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawUsersClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawUsersClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawUsersClient + """ + return self._raw_client + + def user_activity_set( + self, + *, + user_id: str, + external_id: str, + display: UserActivityDisplay, + ttl_seconds: typing.Optional[int] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, + started_at: typing.Optional[dt.datetime] = OMIT, + dnd: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> UserActivity: + """ + Paint a badge (and optional glow) on a user's seat for work happening + outside Roam — a phone call, a browser meeting, a CRM session. Pass + `dnd: true` to also put their assigned office in Do Not Disturb. + + The integration owns the lifecycle: `set` when the session starts, + `clear` when it ends. Re-posting the same `externalId` is the heartbeat + for long-running sessions — it refreshes `expiresAt` and, unless you + send `startedAt`, keeps the original start time. Roam stamps expiry + itself (default 10 minutes, maximum 60) so a dropped "ended" webhook + cannot leave a permanent glow. + + `externalId` is unique per (integration, user). Two apps can hold + activities on the same person at once; you can only update or clear + your own rows. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, stacking, and where the indicator appears on the map. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + Caller-chosen session id, unique per integration and user. + Re-using it upserts the existing row (heartbeat). At most + 128 Unicode code points. + + display : UserActivityDisplay + + ttl_seconds : typing.Optional[int] + Seconds from now until expiry. Mutually exclusive with + `expiresAt`. Values above 3600 are **clamped** to 60 + minutes, not rejected. Default when both are omitted: 600 + (10 minutes). + + expires_at : typing.Optional[dt.datetime] + Absolute expiry (RFC3339, must be in the future). Mutually + exclusive with `ttlSeconds`. Instants more than 60 minutes + ahead are clamped to that maximum. + + started_at : typing.Optional[dt.datetime] + Optional session start (RFC3339). Omit on heartbeats to + preserve the original. A future value is clamped to the + server's now (clock skew; also so one integration cannot + pin the newest-first projection slot). + + dnd : typing.Optional[bool] + If true, this activity contributes Do Not Disturb on the + user's **own assigned office** until it is cleared or + expires. Defaults to false — a badge does not lock an + office unless you opt in. Stacks with Zoom/Meet auto-DND + and other integrations' DND-flagged rows. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UserActivity + Activity saved. Body is the live item (same shape `.list` returns + per entry), including the server-stamped `startedAt` / `expiresAt`. + + Examples + -------- + from roamhq import RoamClient, UserActivityDisplay + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.users.user_activity_set( + user_id="0cc74785-e31e-4403-aa5e-0cc7c1897e66", + external_id="justcall:call:CA123", + display=UserActivityDisplay( + emoji="📞", + title="On a customer call", + subtitle="JustCall · Acme Corp", + color="green", + ), + ttl_seconds=1800, + dnd=True, + ) + """ + _response = self._raw_client.user_activity_set( + user_id=user_id, + external_id=external_id, + display=display, + ttl_seconds=ttl_seconds, + expires_at=expires_at, + started_at=started_at, + dnd=dnd, + request_options=request_options, + ) + return _response.data + + def user_activity_clear( + self, *, user_id: str, external_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + End an activity previously created with [`user.activity.set`](https://developer.ro.am/docs/api/user-activity-set). + The row is keyed by this integration plus `userId` and `externalId` — + you cannot clear another app's activity. + + Clearing a missing, already-cleared, or already-expired `externalId` + still returns **204**. Integrations retry "session ended" webhooks, and + the row may have expired in the meantime. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for TTL, DND + stacking, and what happens on the map when the last activity clears. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + The `externalId` previously passed to `user.activity.set`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.users.user_activity_clear( + user_id="0cc74785-e31e-4403-aa5e-0cc7c1897e66", + external_id="justcall:call:CA123", + ) + """ + _response = self._raw_client.user_activity_clear( + user_id=user_id, external_id=external_id, request_options=request_options + ) + return _response.data + + def user_activity_list( + self, *, user_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> UserActivityListResponse: + """ + Return every **currently live** external activity for a user — every + integration's rows, not only yours. Expired rows are omitted even + before the server reaper runs. Not paginated; ordered newest + `startedAt` first. + + The map may show fewer entries than this list (the client projection + keeps the top three, always including at least one DND-flagged row). + `.list` is the source of truth for what is still live. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, and where indicators appear. + + **Access:** Organization and Personal. Organization tokens may list + any user in the workspace. Personal tokens (OAuth or PAT) may list + only the token owner. + + **Required scope:** `user:read.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only pass + their own user. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UserActivityListResponse + Live activities for the user. `activities` is an empty array when none are set. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.users.user_activity_list( + user_id="userId", + ) + """ + _response = self._raw_client.user_activity_list(user_id=user_id, request_options=request_options) + return _response.data + + def messageevent_export(self, *, date: str, request_options: typing.Optional[RequestOptions] = None) -> str: + """ + Obtain a daily message event export containing DMs and group + chats within your account. + + For customers with archival enabled (please reach out to a Roam + ArchiTech to get this process started), at the end of every day, + we export all message events for a particular day as a JSON Lines file. + This file contains all messages sent: + - by a Roam user who is a member of your organization + - into a chat containing (at the time of export) at least one Roam user who is a member of your organization + - by a bot integration that is part of your organization + + This file also contains message edit and deletion events that meet the above criteria. + We specifically exclude waves, room invitations, and other non-message content + (that may appear as chats within the Roam application) from the export. + + **Access:** Organization only. + + **Required scope:** `admin:compliance:read` + + ### Message Event Structure + + Each line within the file is a JSON object containing the following fields: + - eventType: a string that is one of “sent”, “edited”, or “deleted” + - chatId: a UUIDv4 identifier for a particular chat. All messages within the same chat shared the same chatId. + - threadTimestamp (optional): if part of a thread, the Unix epoch timestamp of the thread’s parent message in numerical format. All messages part of a thread share the same threadTimestamp. + - timestamp: the Unix epoch timestamp when the message was originally sent in numerical format. + - messageId: an internal UUIDv4 identifier as a string + - sender: a “Participant” object that identifiers the message sender + - contentType: a string that is one of the contentTypes associated with the “MessageContent” object + - content: a “MessageContent” object that contains the message’s content + + ### Participant + + A Participant is a JSON object that contains three common fields: “participantType”, “id”, and “displayName” + - participantType: one of “email”, “bot”, or “occupant” + - id: a UUID identifier for the participant + - displayName: the name associated with the account or an empty string if not provided + + Depending on the participant type, the object also contains additional fields: + + Email Participant (a human user with a Roam user account) + - email: the email of the participant + + Bot Participant (an automated user maintained by the Roam team or created via the Roam API) + - roamId: the roam ID associated with the integration + - integrationId: a unique integration ID name provided by the bot creator + - botCode: a unique identifier + + ### Message Content + + A “MessageContent” object is a JSON object that contains the field “contentType” and, + depending on the content type, contains additional fields: + + *Text Content* (contentType = “text”) + - text: the text in plaintext + - markdownText: the text in Markdown format + - attachments: A list of attachment objects + + *Emoji Content* (contentType = “emoji”) + - text: text representation of the emoji + - colons: emoji in :emoji: format + - fileUrl: an optional field containing the URL to a custom emoji image + + *Item Content* (contentType = “item”) + - itemUrl: the URL where the file can be downloaded from + - itemType: the type of item (e.g. "photo", "pdf", "blob", "video", "audio", etc.) + + *Text Snippet Content* (contentType = "textSnippet") + - text: the content of the snippet + - language: the language of the snippet + + *Members Changed Content* (contentType = “membersChanged”) + - added: a list of Participant objects corresponding to all participants added in this event + - removed: a list of Participant objects corresponding to all participants removed in this event + + Parameters + ---------- + date : str + The UTC date to fetch the export for in YYYY-MM-DD format. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + str + Export file returned successfully + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.users.messageevent_export( + date="2026-01-21", + ) + """ + _response = self._raw_client.messageevent_export(date=date, request_options=request_options) + return _response.data + + +class AsyncUsersClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawUsersClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawUsersClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawUsersClient + """ + return self._raw_client + + async def user_activity_set( + self, + *, + user_id: str, + external_id: str, + display: UserActivityDisplay, + ttl_seconds: typing.Optional[int] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, + started_at: typing.Optional[dt.datetime] = OMIT, + dnd: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> UserActivity: + """ + Paint a badge (and optional glow) on a user's seat for work happening + outside Roam — a phone call, a browser meeting, a CRM session. Pass + `dnd: true` to also put their assigned office in Do Not Disturb. + + The integration owns the lifecycle: `set` when the session starts, + `clear` when it ends. Re-posting the same `externalId` is the heartbeat + for long-running sessions — it refreshes `expiresAt` and, unless you + send `startedAt`, keeps the original start time. Roam stamps expiry + itself (default 10 minutes, maximum 60) so a dropped "ended" webhook + cannot leave a permanent glow. + + `externalId` is unique per (integration, user). Two apps can hold + activities on the same person at once; you can only update or clear + your own rows. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, stacking, and where the indicator appears on the map. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + Caller-chosen session id, unique per integration and user. + Re-using it upserts the existing row (heartbeat). At most + 128 Unicode code points. + + display : UserActivityDisplay + + ttl_seconds : typing.Optional[int] + Seconds from now until expiry. Mutually exclusive with + `expiresAt`. Values above 3600 are **clamped** to 60 + minutes, not rejected. Default when both are omitted: 600 + (10 minutes). + + expires_at : typing.Optional[dt.datetime] + Absolute expiry (RFC3339, must be in the future). Mutually + exclusive with `ttlSeconds`. Instants more than 60 minutes + ahead are clamped to that maximum. + + started_at : typing.Optional[dt.datetime] + Optional session start (RFC3339). Omit on heartbeats to + preserve the original. A future value is clamped to the + server's now (clock skew; also so one integration cannot + pin the newest-first projection slot). + + dnd : typing.Optional[bool] + If true, this activity contributes Do Not Disturb on the + user's **own assigned office** until it is cleared or + expires. Defaults to false — a badge does not lock an + office unless you opt in. Stacks with Zoom/Meet auto-DND + and other integrations' DND-flagged rows. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UserActivity + Activity saved. Body is the live item (same shape `.list` returns + per entry), including the server-stamped `startedAt` / `expiresAt`. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient, UserActivityDisplay + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.users.user_activity_set( + user_id="0cc74785-e31e-4403-aa5e-0cc7c1897e66", + external_id="justcall:call:CA123", + display=UserActivityDisplay( + emoji="📞", + title="On a customer call", + subtitle="JustCall · Acme Corp", + color="green", + ), + ttl_seconds=1800, + dnd=True, + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.user_activity_set( + user_id=user_id, + external_id=external_id, + display=display, + ttl_seconds=ttl_seconds, + expires_at=expires_at, + started_at=started_at, + dnd=dnd, + request_options=request_options, + ) + return _response.data + + async def user_activity_clear( + self, *, user_id: str, external_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + End an activity previously created with [`user.activity.set`](https://developer.ro.am/docs/api/user-activity-set). + The row is keyed by this integration plus `userId` and `externalId` — + you cannot clear another app's activity. + + Clearing a missing, already-cleared, or already-expired `externalId` + still returns **204**. Integrations retry "session ended" webhooks, and + the row may have expired in the meantime. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for TTL, DND + stacking, and what happens on the map when the last activity clears. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + The `externalId` previously passed to `user.activity.set`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.users.user_activity_clear( + user_id="0cc74785-e31e-4403-aa5e-0cc7c1897e66", + external_id="justcall:call:CA123", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.user_activity_clear( + user_id=user_id, external_id=external_id, request_options=request_options + ) + return _response.data + + async def user_activity_list( + self, *, user_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> UserActivityListResponse: + """ + Return every **currently live** external activity for a user — every + integration's rows, not only yours. Expired rows are omitted even + before the server reaper runs. Not paginated; ordered newest + `startedAt` first. + + The map may show fewer entries than this list (the client projection + keeps the top three, always including at least one DND-flagged row). + `.list` is the source of truth for what is still live. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, and where indicators appear. + + **Access:** Organization and Personal. Organization tokens may list + any user in the workspace. Personal tokens (OAuth or PAT) may list + only the token owner. + + **Required scope:** `user:read.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only pass + their own user. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + UserActivityListResponse + Live activities for the user. `activities` is an empty array when none are set. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.users.user_activity_list( + user_id="userId", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.user_activity_list(user_id=user_id, request_options=request_options) + return _response.data + + async def messageevent_export(self, *, date: str, request_options: typing.Optional[RequestOptions] = None) -> str: + """ + Obtain a daily message event export containing DMs and group + chats within your account. + + For customers with archival enabled (please reach out to a Roam + ArchiTech to get this process started), at the end of every day, + we export all message events for a particular day as a JSON Lines file. + This file contains all messages sent: + - by a Roam user who is a member of your organization + - into a chat containing (at the time of export) at least one Roam user who is a member of your organization + - by a bot integration that is part of your organization + + This file also contains message edit and deletion events that meet the above criteria. + We specifically exclude waves, room invitations, and other non-message content + (that may appear as chats within the Roam application) from the export. + + **Access:** Organization only. + + **Required scope:** `admin:compliance:read` + + ### Message Event Structure + + Each line within the file is a JSON object containing the following fields: + - eventType: a string that is one of “sent”, “edited”, or “deleted” + - chatId: a UUIDv4 identifier for a particular chat. All messages within the same chat shared the same chatId. + - threadTimestamp (optional): if part of a thread, the Unix epoch timestamp of the thread’s parent message in numerical format. All messages part of a thread share the same threadTimestamp. + - timestamp: the Unix epoch timestamp when the message was originally sent in numerical format. + - messageId: an internal UUIDv4 identifier as a string + - sender: a “Participant” object that identifiers the message sender + - contentType: a string that is one of the contentTypes associated with the “MessageContent” object + - content: a “MessageContent” object that contains the message’s content + + ### Participant + + A Participant is a JSON object that contains three common fields: “participantType”, “id”, and “displayName” + - participantType: one of “email”, “bot”, or “occupant” + - id: a UUID identifier for the participant + - displayName: the name associated with the account or an empty string if not provided + + Depending on the participant type, the object also contains additional fields: + + Email Participant (a human user with a Roam user account) + - email: the email of the participant + + Bot Participant (an automated user maintained by the Roam team or created via the Roam API) + - roamId: the roam ID associated with the integration + - integrationId: a unique integration ID name provided by the bot creator + - botCode: a unique identifier + + ### Message Content + + A “MessageContent” object is a JSON object that contains the field “contentType” and, + depending on the content type, contains additional fields: + + *Text Content* (contentType = “text”) + - text: the text in plaintext + - markdownText: the text in Markdown format + - attachments: A list of attachment objects + + *Emoji Content* (contentType = “emoji”) + - text: text representation of the emoji + - colons: emoji in :emoji: format + - fileUrl: an optional field containing the URL to a custom emoji image + + *Item Content* (contentType = “item”) + - itemUrl: the URL where the file can be downloaded from + - itemType: the type of item (e.g. "photo", "pdf", "blob", "video", "audio", etc.) + + *Text Snippet Content* (contentType = "textSnippet") + - text: the content of the snippet + - language: the language of the snippet + + *Members Changed Content* (contentType = “membersChanged”) + - added: a list of Participant objects corresponding to all participants added in this event + - removed: a list of Participant objects corresponding to all participants removed in this event + + Parameters + ---------- + date : str + The UTC date to fetch the export for in YYYY-MM-DD format. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + str + Export file returned successfully + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.users.messageevent_export( + date="2026-01-21", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.messageevent_export(date=date, request_options=request_options) + return _response.data diff --git a/src/roamhq/users/raw_client.py b/src/roamhq/users/raw_client.py new file mode 100644 index 0000000..e62b605 --- /dev/null +++ b/src/roamhq/users/raw_client.py @@ -0,0 +1,1358 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..core.serialization import convert_and_respect_annotation_metadata +from ..errors.bad_request_error import BadRequestError +from ..errors.forbidden_error import ForbiddenError +from ..errors.internal_server_error import InternalServerError +from ..errors.method_not_allowed_error import MethodNotAllowedError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from ..types.user_activity import UserActivity +from ..types.user_activity_display import UserActivityDisplay +from .types.user_activity_list_response import UserActivityListResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawUsersClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def user_activity_set( + self, + *, + user_id: str, + external_id: str, + display: UserActivityDisplay, + ttl_seconds: typing.Optional[int] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, + started_at: typing.Optional[dt.datetime] = OMIT, + dnd: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[UserActivity]: + """ + Paint a badge (and optional glow) on a user's seat for work happening + outside Roam — a phone call, a browser meeting, a CRM session. Pass + `dnd: true` to also put their assigned office in Do Not Disturb. + + The integration owns the lifecycle: `set` when the session starts, + `clear` when it ends. Re-posting the same `externalId` is the heartbeat + for long-running sessions — it refreshes `expiresAt` and, unless you + send `startedAt`, keeps the original start time. Roam stamps expiry + itself (default 10 minutes, maximum 60) so a dropped "ended" webhook + cannot leave a permanent glow. + + `externalId` is unique per (integration, user). Two apps can hold + activities on the same person at once; you can only update or clear + your own rows. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, stacking, and where the indicator appears on the map. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + Caller-chosen session id, unique per integration and user. + Re-using it upserts the existing row (heartbeat). At most + 128 Unicode code points. + + display : UserActivityDisplay + + ttl_seconds : typing.Optional[int] + Seconds from now until expiry. Mutually exclusive with + `expiresAt`. Values above 3600 are **clamped** to 60 + minutes, not rejected. Default when both are omitted: 600 + (10 minutes). + + expires_at : typing.Optional[dt.datetime] + Absolute expiry (RFC3339, must be in the future). Mutually + exclusive with `ttlSeconds`. Instants more than 60 minutes + ahead are clamped to that maximum. + + started_at : typing.Optional[dt.datetime] + Optional session start (RFC3339). Omit on heartbeats to + preserve the original. A future value is clamped to the + server's now (clock skew; also so one integration cannot + pin the newest-first projection slot). + + dnd : typing.Optional[bool] + If true, this activity contributes Do Not Disturb on the + user's **own assigned office** until it is cleared or + expires. Defaults to false — a badge does not lock an + office unless you opt in. Stacks with Zoom/Meet auto-DND + and other integrations' DND-flagged rows. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[UserActivity] + Activity saved. Body is the live item (same shape `.list` returns + per entry), including the server-stamped `startedAt` / `expiresAt`. + """ + _response = self._client_wrapper.httpx_client.request( + "user.activity.set", + method="POST", + json={ + "userId": user_id, + "externalId": external_id, + "display": convert_and_respect_annotation_metadata( + object_=display, annotation=UserActivityDisplay, direction="write" + ), + "ttlSeconds": ttl_seconds, + "expiresAt": expires_at, + "startedAt": started_at, + "dnd": dnd, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UserActivity, + parse_obj_as( + type_=UserActivity, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def user_activity_clear( + self, *, user_id: str, external_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[None]: + """ + End an activity previously created with [`user.activity.set`](https://developer.ro.am/docs/api/user-activity-set). + The row is keyed by this integration plus `userId` and `externalId` — + you cannot clear another app's activity. + + Clearing a missing, already-cleared, or already-expired `externalId` + still returns **204**. Integrations retry "session ended" webhooks, and + the row may have expired in the meantime. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for TTL, DND + stacking, and what happens on the map when the last activity clears. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + The `externalId` previously passed to `user.activity.set`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "user.activity.clear", + method="POST", + json={ + "userId": user_id, + "externalId": external_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def user_activity_list( + self, *, user_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[UserActivityListResponse]: + """ + Return every **currently live** external activity for a user — every + integration's rows, not only yours. Expired rows are omitted even + before the server reaper runs. Not paginated; ordered newest + `startedAt` first. + + The map may show fewer entries than this list (the client projection + keeps the top three, always including at least one DND-flagged row). + `.list` is the source of truth for what is still live. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, and where indicators appear. + + **Access:** Organization and Personal. Organization tokens may list + any user in the workspace. Personal tokens (OAuth or PAT) may list + only the token owner. + + **Required scope:** `user:read.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only pass + their own user. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[UserActivityListResponse] + Live activities for the user. `activities` is an empty array when none are set. + """ + _response = self._client_wrapper.httpx_client.request( + "user.activity.list", + method="GET", + params={ + "userId": user_id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UserActivityListResponse, + parse_obj_as( + type_=UserActivityListResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def messageevent_export( + self, *, date: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[str]: + """ + Obtain a daily message event export containing DMs and group + chats within your account. + + For customers with archival enabled (please reach out to a Roam + ArchiTech to get this process started), at the end of every day, + we export all message events for a particular day as a JSON Lines file. + This file contains all messages sent: + - by a Roam user who is a member of your organization + - into a chat containing (at the time of export) at least one Roam user who is a member of your organization + - by a bot integration that is part of your organization + + This file also contains message edit and deletion events that meet the above criteria. + We specifically exclude waves, room invitations, and other non-message content + (that may appear as chats within the Roam application) from the export. + + **Access:** Organization only. + + **Required scope:** `admin:compliance:read` + + ### Message Event Structure + + Each line within the file is a JSON object containing the following fields: + - eventType: a string that is one of “sent”, “edited”, or “deleted” + - chatId: a UUIDv4 identifier for a particular chat. All messages within the same chat shared the same chatId. + - threadTimestamp (optional): if part of a thread, the Unix epoch timestamp of the thread’s parent message in numerical format. All messages part of a thread share the same threadTimestamp. + - timestamp: the Unix epoch timestamp when the message was originally sent in numerical format. + - messageId: an internal UUIDv4 identifier as a string + - sender: a “Participant” object that identifiers the message sender + - contentType: a string that is one of the contentTypes associated with the “MessageContent” object + - content: a “MessageContent” object that contains the message’s content + + ### Participant + + A Participant is a JSON object that contains three common fields: “participantType”, “id”, and “displayName” + - participantType: one of “email”, “bot”, or “occupant” + - id: a UUID identifier for the participant + - displayName: the name associated with the account or an empty string if not provided + + Depending on the participant type, the object also contains additional fields: + + Email Participant (a human user with a Roam user account) + - email: the email of the participant + + Bot Participant (an automated user maintained by the Roam team or created via the Roam API) + - roamId: the roam ID associated with the integration + - integrationId: a unique integration ID name provided by the bot creator + - botCode: a unique identifier + + ### Message Content + + A “MessageContent” object is a JSON object that contains the field “contentType” and, + depending on the content type, contains additional fields: + + *Text Content* (contentType = “text”) + - text: the text in plaintext + - markdownText: the text in Markdown format + - attachments: A list of attachment objects + + *Emoji Content* (contentType = “emoji”) + - text: text representation of the emoji + - colons: emoji in :emoji: format + - fileUrl: an optional field containing the URL to a custom emoji image + + *Item Content* (contentType = “item”) + - itemUrl: the URL where the file can be downloaded from + - itemType: the type of item (e.g. "photo", "pdf", "blob", "video", "audio", etc.) + + *Text Snippet Content* (contentType = "textSnippet") + - text: the content of the snippet + - language: the language of the snippet + + *Members Changed Content* (contentType = “membersChanged”) + - added: a list of Participant objects corresponding to all participants added in this event + - removed: a list of Participant objects corresponding to all participants removed in this event + + Parameters + ---------- + date : str + The UTC date to fetch the export for in YYYY-MM-DD format. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[str] + Export file returned successfully + """ + _response = self._client_wrapper.httpx_client.request( + "messageevent.export", + method="POST", + json={ + "date": date, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + str, + parse_obj_as( + type_=str, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawUsersClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def user_activity_set( + self, + *, + user_id: str, + external_id: str, + display: UserActivityDisplay, + ttl_seconds: typing.Optional[int] = OMIT, + expires_at: typing.Optional[dt.datetime] = OMIT, + started_at: typing.Optional[dt.datetime] = OMIT, + dnd: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[UserActivity]: + """ + Paint a badge (and optional glow) on a user's seat for work happening + outside Roam — a phone call, a browser meeting, a CRM session. Pass + `dnd: true` to also put their assigned office in Do Not Disturb. + + The integration owns the lifecycle: `set` when the session starts, + `clear` when it ends. Re-posting the same `externalId` is the heartbeat + for long-running sessions — it refreshes `expiresAt` and, unless you + send `startedAt`, keeps the original start time. Roam stamps expiry + itself (default 10 minutes, maximum 60) so a dropped "ended" webhook + cannot leave a permanent glow. + + `externalId` is unique per (integration, user). Two apps can hold + activities on the same person at once; you can only update or clear + your own rows. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, stacking, and where the indicator appears on the map. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + Caller-chosen session id, unique per integration and user. + Re-using it upserts the existing row (heartbeat). At most + 128 Unicode code points. + + display : UserActivityDisplay + + ttl_seconds : typing.Optional[int] + Seconds from now until expiry. Mutually exclusive with + `expiresAt`. Values above 3600 are **clamped** to 60 + minutes, not rejected. Default when both are omitted: 600 + (10 minutes). + + expires_at : typing.Optional[dt.datetime] + Absolute expiry (RFC3339, must be in the future). Mutually + exclusive with `ttlSeconds`. Instants more than 60 minutes + ahead are clamped to that maximum. + + started_at : typing.Optional[dt.datetime] + Optional session start (RFC3339). Omit on heartbeats to + preserve the original. A future value is clamped to the + server's now (clock skew; also so one integration cannot + pin the newest-first projection slot). + + dnd : typing.Optional[bool] + If true, this activity contributes Do Not Disturb on the + user's **own assigned office** until it is cleared or + expires. Defaults to false — a badge does not lock an + office unless you opt in. Stacks with Zoom/Meet auto-DND + and other integrations' DND-flagged rows. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[UserActivity] + Activity saved. Body is the live item (same shape `.list` returns + per entry), including the server-stamped `startedAt` / `expiresAt`. + """ + _response = await self._client_wrapper.httpx_client.request( + "user.activity.set", + method="POST", + json={ + "userId": user_id, + "externalId": external_id, + "display": convert_and_respect_annotation_metadata( + object_=display, annotation=UserActivityDisplay, direction="write" + ), + "ttlSeconds": ttl_seconds, + "expiresAt": expires_at, + "startedAt": started_at, + "dnd": dnd, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UserActivity, + parse_obj_as( + type_=UserActivity, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def user_activity_clear( + self, *, user_id: str, external_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: + """ + End an activity previously created with [`user.activity.set`](https://developer.ro.am/docs/api/user-activity-set). + The row is keyed by this integration plus `userId` and `externalId` — + you cannot clear another app's activity. + + Clearing a missing, already-cleared, or already-expired `externalId` + still returns **204**. Integrations retry "session ended" webhooks, and + the row may have expired in the meantime. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for TTL, DND + stacking, and what happens on the map when the last activity clears. + + **Access:** Organization and Personal. Organization tokens may target + any user in the workspace. Personal tokens (OAuth or PAT) may target + only the token owner. + + **Required scope:** `user:write.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only + pass their own user. + + external_id : str + The `externalId` previously passed to `user.activity.set`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "user.activity.clear", + method="POST", + json={ + "userId": user_id, + "externalId": external_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def user_activity_list( + self, *, user_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[UserActivityListResponse]: + """ + Return every **currently live** external activity for a user — every + integration's rows, not only yours. Expired rows are omitted even + before the server reaper runs. Not paginated; ordered newest + `startedAt` first. + + The map may show fewer entries than this list (the client projection + keeps the top three, always including at least one DND-flagged row). + `.list` is the source of truth for what is still live. + + See [External activity](https://developer.ro.am/docs/guides/user-activity) for display, DND, + TTL, and where indicators appear. + + **Access:** Organization and Personal. Organization tokens may list + any user in the workspace. Personal tokens (OAuth or PAT) may list + only the token owner. + + **Required scope:** `user:read.activity`. Personal Access Tokens skip + this check; personal-mode OAuth installs must still request the scope. + + Parameters + ---------- + user_id : str + Target user. Bare or tagged UUID. Personal tokens may only pass + their own user. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[UserActivityListResponse] + Live activities for the user. `activities` is an empty array when none are set. + """ + _response = await self._client_wrapper.httpx_client.request( + "user.activity.list", + method="GET", + params={ + "userId": user_id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + UserActivityListResponse, + parse_obj_as( + type_=UserActivityListResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def messageevent_export( + self, *, date: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[str]: + """ + Obtain a daily message event export containing DMs and group + chats within your account. + + For customers with archival enabled (please reach out to a Roam + ArchiTech to get this process started), at the end of every day, + we export all message events for a particular day as a JSON Lines file. + This file contains all messages sent: + - by a Roam user who is a member of your organization + - into a chat containing (at the time of export) at least one Roam user who is a member of your organization + - by a bot integration that is part of your organization + + This file also contains message edit and deletion events that meet the above criteria. + We specifically exclude waves, room invitations, and other non-message content + (that may appear as chats within the Roam application) from the export. + + **Access:** Organization only. + + **Required scope:** `admin:compliance:read` + + ### Message Event Structure + + Each line within the file is a JSON object containing the following fields: + - eventType: a string that is one of “sent”, “edited”, or “deleted” + - chatId: a UUIDv4 identifier for a particular chat. All messages within the same chat shared the same chatId. + - threadTimestamp (optional): if part of a thread, the Unix epoch timestamp of the thread’s parent message in numerical format. All messages part of a thread share the same threadTimestamp. + - timestamp: the Unix epoch timestamp when the message was originally sent in numerical format. + - messageId: an internal UUIDv4 identifier as a string + - sender: a “Participant” object that identifiers the message sender + - contentType: a string that is one of the contentTypes associated with the “MessageContent” object + - content: a “MessageContent” object that contains the message’s content + + ### Participant + + A Participant is a JSON object that contains three common fields: “participantType”, “id”, and “displayName” + - participantType: one of “email”, “bot”, or “occupant” + - id: a UUID identifier for the participant + - displayName: the name associated with the account or an empty string if not provided + + Depending on the participant type, the object also contains additional fields: + + Email Participant (a human user with a Roam user account) + - email: the email of the participant + + Bot Participant (an automated user maintained by the Roam team or created via the Roam API) + - roamId: the roam ID associated with the integration + - integrationId: a unique integration ID name provided by the bot creator + - botCode: a unique identifier + + ### Message Content + + A “MessageContent” object is a JSON object that contains the field “contentType” and, + depending on the content type, contains additional fields: + + *Text Content* (contentType = “text”) + - text: the text in plaintext + - markdownText: the text in Markdown format + - attachments: A list of attachment objects + + *Emoji Content* (contentType = “emoji”) + - text: text representation of the emoji + - colons: emoji in :emoji: format + - fileUrl: an optional field containing the URL to a custom emoji image + + *Item Content* (contentType = “item”) + - itemUrl: the URL where the file can be downloaded from + - itemType: the type of item (e.g. "photo", "pdf", "blob", "video", "audio", etc.) + + *Text Snippet Content* (contentType = "textSnippet") + - text: the content of the snippet + - language: the language of the snippet + + *Members Changed Content* (contentType = “membersChanged”) + - added: a list of Participant objects corresponding to all participants added in this event + - removed: a list of Participant objects corresponding to all participants removed in this event + + Parameters + ---------- + date : str + The UTC date to fetch the export for in YYYY-MM-DD format. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[str] + Export file returned successfully + """ + _response = await self._client_wrapper.httpx_client.request( + "messageevent.export", + method="POST", + json={ + "date": date, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + str, + parse_obj_as( + type_=str, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 405: + raise MethodNotAllowedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/users/types/__init__.py b/src/roamhq/users/types/__init__.py new file mode 100644 index 0000000..916017a --- /dev/null +++ b/src/roamhq/users/types/__init__.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .user_activity_list_response import UserActivityListResponse +_dynamic_imports: typing.Dict[str, str] = {"UserActivityListResponse": ".user_activity_list_response"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["UserActivityListResponse"] diff --git a/src/roamhq/users/types/user_activity_list_response.py b/src/roamhq/users/types/user_activity_list_response.py new file mode 100644 index 0000000..28ae808 --- /dev/null +++ b/src/roamhq/users/types/user_activity_list_response.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...types.user_activity import UserActivity + + +class UserActivityListResponse(UniversalBaseModel): + activities: typing.List[UserActivity] + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/webhook/__init__.py b/src/roamhq/webhook/__init__.py new file mode 100644 index 0000000..99f3915 --- /dev/null +++ b/src/roamhq/webhook/__init__.py @@ -0,0 +1,54 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + DeliveriesWebhookResponse, + DeliveriesWebhookResponseDeliveriesItem, + ListWebhookResponse, + ListWebhookResponseWebhooksItem, + WebhookSubscriptionRequestEvent, + ) +_dynamic_imports: typing.Dict[str, str] = { + "DeliveriesWebhookResponse": ".types", + "DeliveriesWebhookResponseDeliveriesItem": ".types", + "ListWebhookResponse": ".types", + "ListWebhookResponseWebhooksItem": ".types", + "WebhookSubscriptionRequestEvent": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "DeliveriesWebhookResponse", + "DeliveriesWebhookResponseDeliveriesItem", + "ListWebhookResponse", + "ListWebhookResponseWebhooksItem", + "WebhookSubscriptionRequestEvent", +] diff --git a/src/roamhq/webhook/client.py b/src/roamhq/webhook/client.py new file mode 100644 index 0000000..6271995 --- /dev/null +++ b/src/roamhq/webhook/client.py @@ -0,0 +1,533 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.request_options import RequestOptions +from ..types.webhook import Webhook +from ..types.webhook_subscription_filter import WebhookSubscriptionFilter +from .raw_client import AsyncRawWebhookClient, RawWebhookClient +from .types.deliveries_webhook_response import DeliveriesWebhookResponse +from .types.list_webhook_response import ListWebhookResponse +from .types.webhook_subscription_request_event import WebhookSubscriptionRequestEvent + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class WebhookClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawWebhookClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawWebhookClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawWebhookClient + """ + return self._raw_client + + def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListWebhookResponse: + """ + List all webhook subscriptions owned by the authenticated API client. + + The response includes both **dynamic** subscriptions (created via + [`/webhook.subscribe`](https://developer.ro.am/docs/webhooks/webhook-subscribe)) and **static** + subscriptions configured in the Roam Administration UI. + + Each object may include `lastSuccessAt`, `failStreakStartedAt`, and + `disabledAt` (omitted when null). `disabledAt` means the destination is + paused. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Required scope:** `webhook:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListWebhookResponse + List of webhook subscriptions. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.webhook.list() + """ + _response = self._raw_client.list(request_options=request_options) + return _response.data + + def subscribe( + self, + *, + url: str, + event: WebhookSubscriptionRequestEvent, + filter: typing.Optional[WebhookSubscriptionFilter] = OMIT, + api_version: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Webhook: + """ + Create or update a webhook subscription for a given event. If a subscription + already exists for the same event and URL, its filter is updated instead of + creating a duplicate. Re-subscribing the same event and URL also clears a + pause (`disabledAt` / `failStreakStartedAt`) so deliveries resume on the + next event. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Event names are dotted:** `chat.message`, `lobby.booked`, + `magicast.created`. Colon names (`chat:message:dm`, `lobby:booked`) are + v0-only — sending them here returns `400` / `Unrecognized event`. + + Roam does not probe the destination URL when you subscribe — the + subscription is created immediately and the first delivery is a real event. + + See the [Webhooks overview](https://developer.ro.am/docs/webhooks/webhooks) for the full list of event names and their filters. + + **Required scope:** `webhook:write` + + Parameters + ---------- + url : str + Destination URL for webhook deliveries (max 1024 characters). HTTPS is required outside local environments. + + event : WebhookSubscriptionRequestEvent + Event to subscribe to. + + filter : typing.Optional[WebhookSubscriptionFilter] + + api_version : typing.Optional[str] + Optional [API version](https://developer.ro.am/docs/guides/api-versioning) (`YYYY-MM-DD`) to pin + this subscription's payload shape to. When omitted, the subscription is + frozen at your integration's default version. Unsupported values return + `400`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Webhook + Subscription created or updated. + + Examples + -------- + from roamhq import RoamClient, WebhookSubscriptionFilter + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.webhook.subscribe( + url="https://example.com/hooks/messages", + event="chat.message", + filter=WebhookSubscriptionFilter( + mention=True, + ), + ) + """ + _response = self._raw_client.subscribe( + url=url, event=event, filter=filter, api_version=api_version, request_options=request_options + ) + return _response.data + + def unsubscribe(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Remove a webhook subscription by ID. + + The request body is JSON: `{"id": ""}`. This differs + from v0, which expects `application/x-www-form-urlencoded` with the same + `id` field. Sending JSON to `/v0/webhook.unsubscribe` returns + `id parameter required`. + + **Required scope:** `webhook:write` + + Parameters + ---------- + id : str + Identifier of the webhook subscription to remove. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.webhook.unsubscribe( + id="19c6401f-6d02-4d8c-87c5-9fc45f02f4b5", + ) + """ + _response = self._raw_client.unsubscribe(id=id, request_options=request_options) + return _response.data + + def deliveries( + self, + *, + webhook: typing.Optional[str] = None, + event: typing.Optional[str] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> DeliveriesWebhookResponse: + """ + List recent **failed** webhook delivery attempts for the authenticated API + client, newest first. Use this to debug a misbehaving endpoint and to find + the events you need to replay: successful (2xx) deliveries are never + recorded, so every row here is a delivery your endpoint did not accept. + + Timeouts are first-class failures: `statusCode` is `0` and `error` is + `timeout`. For HTTP error responses, a truncated copy of your server's + response body is included to aid debugging. The request payload is never + stored — to recover the data, re-fetch the underlying resource (e.g. via + `chat.history`) using the delivery's `messageId`/`event` context. + + Results are strictly scoped to the caller's own subscriptions and retained + for roughly 30 days. + + **Access:** Organization and Personal. + + **Required scope:** `webhook:read` + + Parameters + ---------- + webhook : typing.Optional[str] + Only return deliveries for this webhook subscription ID. + + event : typing.Optional[str] + Only return deliveries for this event name (e.g. `chat.message`). + + after : typing.Optional[str] + Only return deliveries after this time (RFC3339 or `YYYY-MM-DD`). Results switch to oldest-first. + + before : typing.Optional[str] + Only return deliveries before this time (RFC3339 or `YYYY-MM-DD`). + + limit : typing.Optional[int] + Maximum number of deliveries to return. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeliveriesWebhookResponse + Failed delivery attempts for the caller's subscriptions. + + Examples + -------- + from roamhq import RoamClient + + client = RoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + client.webhook.deliveries() + """ + _response = self._raw_client.deliveries( + webhook=webhook, + event=event, + after=after, + before=before, + limit=limit, + cursor=cursor, + request_options=request_options, + ) + return _response.data + + +class AsyncWebhookClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawWebhookClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawWebhookClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawWebhookClient + """ + return self._raw_client + + async def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListWebhookResponse: + """ + List all webhook subscriptions owned by the authenticated API client. + + The response includes both **dynamic** subscriptions (created via + [`/webhook.subscribe`](https://developer.ro.am/docs/webhooks/webhook-subscribe)) and **static** + subscriptions configured in the Roam Administration UI. + + Each object may include `lastSuccessAt`, `failStreakStartedAt`, and + `disabledAt` (omitted when null). `disabledAt` means the destination is + paused. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Required scope:** `webhook:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListWebhookResponse + List of webhook subscriptions. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.webhook.list() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list(request_options=request_options) + return _response.data + + async def subscribe( + self, + *, + url: str, + event: WebhookSubscriptionRequestEvent, + filter: typing.Optional[WebhookSubscriptionFilter] = OMIT, + api_version: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> Webhook: + """ + Create or update a webhook subscription for a given event. If a subscription + already exists for the same event and URL, its filter is updated instead of + creating a duplicate. Re-subscribing the same event and URL also clears a + pause (`disabledAt` / `failStreakStartedAt`) so deliveries resume on the + next event. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Event names are dotted:** `chat.message`, `lobby.booked`, + `magicast.created`. Colon names (`chat:message:dm`, `lobby:booked`) are + v0-only — sending them here returns `400` / `Unrecognized event`. + + Roam does not probe the destination URL when you subscribe — the + subscription is created immediately and the first delivery is a real event. + + See the [Webhooks overview](https://developer.ro.am/docs/webhooks/webhooks) for the full list of event names and their filters. + + **Required scope:** `webhook:write` + + Parameters + ---------- + url : str + Destination URL for webhook deliveries (max 1024 characters). HTTPS is required outside local environments. + + event : WebhookSubscriptionRequestEvent + Event to subscribe to. + + filter : typing.Optional[WebhookSubscriptionFilter] + + api_version : typing.Optional[str] + Optional [API version](https://developer.ro.am/docs/guides/api-versioning) (`YYYY-MM-DD`) to pin + this subscription's payload shape to. When omitted, the subscription is + frozen at your integration's default version. Unsupported values return + `400`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + Webhook + Subscription created or updated. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient, WebhookSubscriptionFilter + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.webhook.subscribe( + url="https://example.com/hooks/messages", + event="chat.message", + filter=WebhookSubscriptionFilter( + mention=True, + ), + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.subscribe( + url=url, event=event, filter=filter, api_version=api_version, request_options=request_options + ) + return _response.data + + async def unsubscribe(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> None: + """ + Remove a webhook subscription by ID. + + The request body is JSON: `{"id": ""}`. This differs + from v0, which expects `application/x-www-form-urlencoded` with the same + `id` field. Sending JSON to `/v0/webhook.unsubscribe` returns + `id parameter required`. + + **Required scope:** `webhook:write` + + Parameters + ---------- + id : str + Identifier of the webhook subscription to remove. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.webhook.unsubscribe( + id="19c6401f-6d02-4d8c-87c5-9fc45f02f4b5", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.unsubscribe(id=id, request_options=request_options) + return _response.data + + async def deliveries( + self, + *, + webhook: typing.Optional[str] = None, + event: typing.Optional[str] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> DeliveriesWebhookResponse: + """ + List recent **failed** webhook delivery attempts for the authenticated API + client, newest first. Use this to debug a misbehaving endpoint and to find + the events you need to replay: successful (2xx) deliveries are never + recorded, so every row here is a delivery your endpoint did not accept. + + Timeouts are first-class failures: `statusCode` is `0` and `error` is + `timeout`. For HTTP error responses, a truncated copy of your server's + response body is included to aid debugging. The request payload is never + stored — to recover the data, re-fetch the underlying resource (e.g. via + `chat.history`) using the delivery's `messageId`/`event` context. + + Results are strictly scoped to the caller's own subscriptions and retained + for roughly 30 days. + + **Access:** Organization and Personal. + + **Required scope:** `webhook:read` + + Parameters + ---------- + webhook : typing.Optional[str] + Only return deliveries for this webhook subscription ID. + + event : typing.Optional[str] + Only return deliveries for this event name (e.g. `chat.message`). + + after : typing.Optional[str] + Only return deliveries after this time (RFC3339 or `YYYY-MM-DD`). Results switch to oldest-first. + + before : typing.Optional[str] + Only return deliveries before this time (RFC3339 or `YYYY-MM-DD`). + + limit : typing.Optional[int] + Maximum number of deliveries to return. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + DeliveriesWebhookResponse + Failed delivery attempts for the caller's subscriptions. + + Examples + -------- + import asyncio + + from roamhq import AsyncRoamClient + + client = AsyncRoamClient( + roam_version="YOUR_ROAM_VERSION", + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.webhook.deliveries() + + + asyncio.run(main()) + """ + _response = await self._raw_client.deliveries( + webhook=webhook, + event=event, + after=after, + before=before, + limit=limit, + cursor=cursor, + request_options=request_options, + ) + return _response.data diff --git a/src/roamhq/webhook/raw_client.py b/src/roamhq/webhook/raw_client.py new file mode 100644 index 0000000..1b4a54a --- /dev/null +++ b/src/roamhq/webhook/raw_client.py @@ -0,0 +1,939 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing +from json.decoder import JSONDecodeError + +from ..core.api_error import ApiError +from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ..core.http_response import AsyncHttpResponse, HttpResponse +from ..core.parse_error import ParsingError +from ..core.pydantic_utilities import parse_obj_as +from ..core.request_options import RequestOptions +from ..core.serialization import convert_and_respect_annotation_metadata +from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError +from ..errors.not_found_error import NotFoundError +from ..errors.too_many_requests_error import TooManyRequestsError +from ..errors.unauthorized_error import UnauthorizedError +from ..types.error import Error +from ..types.webhook import Webhook +from ..types.webhook_subscription_filter import WebhookSubscriptionFilter +from .types.deliveries_webhook_response import DeliveriesWebhookResponse +from .types.list_webhook_response import ListWebhookResponse +from .types.webhook_subscription_request_event import WebhookSubscriptionRequestEvent +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawWebhookClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[ListWebhookResponse]: + """ + List all webhook subscriptions owned by the authenticated API client. + + The response includes both **dynamic** subscriptions (created via + [`/webhook.subscribe`](https://developer.ro.am/docs/webhooks/webhook-subscribe)) and **static** + subscriptions configured in the Roam Administration UI. + + Each object may include `lastSuccessAt`, `failStreakStartedAt`, and + `disabledAt` (omitted when null). `disabledAt` means the destination is + paused. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Required scope:** `webhook:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListWebhookResponse] + List of webhook subscriptions. + """ + _response = self._client_wrapper.httpx_client.request( + "webhook.list", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListWebhookResponse, + parse_obj_as( + type_=ListWebhookResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def subscribe( + self, + *, + url: str, + event: WebhookSubscriptionRequestEvent, + filter: typing.Optional[WebhookSubscriptionFilter] = OMIT, + api_version: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[Webhook]: + """ + Create or update a webhook subscription for a given event. If a subscription + already exists for the same event and URL, its filter is updated instead of + creating a duplicate. Re-subscribing the same event and URL also clears a + pause (`disabledAt` / `failStreakStartedAt`) so deliveries resume on the + next event. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Event names are dotted:** `chat.message`, `lobby.booked`, + `magicast.created`. Colon names (`chat:message:dm`, `lobby:booked`) are + v0-only — sending them here returns `400` / `Unrecognized event`. + + Roam does not probe the destination URL when you subscribe — the + subscription is created immediately and the first delivery is a real event. + + See the [Webhooks overview](https://developer.ro.am/docs/webhooks/webhooks) for the full list of event names and their filters. + + **Required scope:** `webhook:write` + + Parameters + ---------- + url : str + Destination URL for webhook deliveries (max 1024 characters). HTTPS is required outside local environments. + + event : WebhookSubscriptionRequestEvent + Event to subscribe to. + + filter : typing.Optional[WebhookSubscriptionFilter] + + api_version : typing.Optional[str] + Optional [API version](https://developer.ro.am/docs/guides/api-versioning) (`YYYY-MM-DD`) to pin + this subscription's payload shape to. When omitted, the subscription is + frozen at your integration's default version. Unsupported values return + `400`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[Webhook] + Subscription created or updated. + """ + _response = self._client_wrapper.httpx_client.request( + "webhook.subscribe", + method="POST", + json={ + "url": url, + "event": event, + "filter": convert_and_respect_annotation_metadata( + object_=filter, annotation=typing.Optional[WebhookSubscriptionFilter], direction="write" + ), + "apiVersion": api_version, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Webhook, + parse_obj_as( + type_=Webhook, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def unsubscribe(self, *, id: str, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: + """ + Remove a webhook subscription by ID. + + The request body is JSON: `{"id": ""}`. This differs + from v0, which expects `application/x-www-form-urlencoded` with the same + `id` field. Sending JSON to `/v0/webhook.unsubscribe` returns + `id parameter required`. + + **Required scope:** `webhook:write` + + Parameters + ---------- + id : str + Identifier of the webhook subscription to remove. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "webhook.unsubscribe", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def deliveries( + self, + *, + webhook: typing.Optional[str] = None, + event: typing.Optional[str] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[DeliveriesWebhookResponse]: + """ + List recent **failed** webhook delivery attempts for the authenticated API + client, newest first. Use this to debug a misbehaving endpoint and to find + the events you need to replay: successful (2xx) deliveries are never + recorded, so every row here is a delivery your endpoint did not accept. + + Timeouts are first-class failures: `statusCode` is `0` and `error` is + `timeout`. For HTTP error responses, a truncated copy of your server's + response body is included to aid debugging. The request payload is never + stored — to recover the data, re-fetch the underlying resource (e.g. via + `chat.history`) using the delivery's `messageId`/`event` context. + + Results are strictly scoped to the caller's own subscriptions and retained + for roughly 30 days. + + **Access:** Organization and Personal. + + **Required scope:** `webhook:read` + + Parameters + ---------- + webhook : typing.Optional[str] + Only return deliveries for this webhook subscription ID. + + event : typing.Optional[str] + Only return deliveries for this event name (e.g. `chat.message`). + + after : typing.Optional[str] + Only return deliveries after this time (RFC3339 or `YYYY-MM-DD`). Results switch to oldest-first. + + before : typing.Optional[str] + Only return deliveries before this time (RFC3339 or `YYYY-MM-DD`). + + limit : typing.Optional[int] + Maximum number of deliveries to return. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[DeliveriesWebhookResponse] + Failed delivery attempts for the caller's subscriptions. + """ + _response = self._client_wrapper.httpx_client.request( + "webhook.deliveries", + method="GET", + params={ + "webhook": webhook, + "event": event, + "after": after, + "before": before, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeliveriesWebhookResponse, + parse_obj_as( + type_=DeliveriesWebhookResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawWebhookClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ListWebhookResponse]: + """ + List all webhook subscriptions owned by the authenticated API client. + + The response includes both **dynamic** subscriptions (created via + [`/webhook.subscribe`](https://developer.ro.am/docs/webhooks/webhook-subscribe)) and **static** + subscriptions configured in the Roam Administration UI. + + Each object may include `lastSuccessAt`, `failStreakStartedAt`, and + `disabledAt` (omitted when null). `disabledAt` means the destination is + paused. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Required scope:** `webhook:read` + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListWebhookResponse] + List of webhook subscriptions. + """ + _response = await self._client_wrapper.httpx_client.request( + "webhook.list", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListWebhookResponse, + parse_obj_as( + type_=ListWebhookResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def subscribe( + self, + *, + url: str, + event: WebhookSubscriptionRequestEvent, + filter: typing.Optional[WebhookSubscriptionFilter] = OMIT, + api_version: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[Webhook]: + """ + Create or update a webhook subscription for a given event. If a subscription + already exists for the same event and URL, its filter is updated instead of + creating a duplicate. Re-subscribing the same event and URL also clears a + pause (`disabledAt` / `failStreakStartedAt`) so deliveries resume on the + next event. See [Subscription health](https://developer.ro.am/docs/webhooks/webhooks#subscription-health). + + **Event names are dotted:** `chat.message`, `lobby.booked`, + `magicast.created`. Colon names (`chat:message:dm`, `lobby:booked`) are + v0-only — sending them here returns `400` / `Unrecognized event`. + + Roam does not probe the destination URL when you subscribe — the + subscription is created immediately and the first delivery is a real event. + + See the [Webhooks overview](https://developer.ro.am/docs/webhooks/webhooks) for the full list of event names and their filters. + + **Required scope:** `webhook:write` + + Parameters + ---------- + url : str + Destination URL for webhook deliveries (max 1024 characters). HTTPS is required outside local environments. + + event : WebhookSubscriptionRequestEvent + Event to subscribe to. + + filter : typing.Optional[WebhookSubscriptionFilter] + + api_version : typing.Optional[str] + Optional [API version](https://developer.ro.am/docs/guides/api-versioning) (`YYYY-MM-DD`) to pin + this subscription's payload shape to. When omitted, the subscription is + frozen at your integration's default version. Unsupported values return + `400`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[Webhook] + Subscription created or updated. + """ + _response = await self._client_wrapper.httpx_client.request( + "webhook.subscribe", + method="POST", + json={ + "url": url, + "event": event, + "filter": convert_and_respect_annotation_metadata( + object_=filter, annotation=typing.Optional[WebhookSubscriptionFilter], direction="write" + ), + "apiVersion": api_version, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + Webhook, + parse_obj_as( + type_=Webhook, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def unsubscribe( + self, *, id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: + """ + Remove a webhook subscription by ID. + + The request body is JSON: `{"id": ""}`. This differs + from v0, which expects `application/x-www-form-urlencoded` with the same + `id` field. Sending JSON to `/v0/webhook.unsubscribe` returns + `id parameter required`. + + **Required scope:** `webhook:write` + + Parameters + ---------- + id : str + Identifier of the webhook subscription to remove. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "webhook.unsubscribe", + method="POST", + json={ + "id": id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def deliveries( + self, + *, + webhook: typing.Optional[str] = None, + event: typing.Optional[str] = None, + after: typing.Optional[str] = None, + before: typing.Optional[str] = None, + limit: typing.Optional[int] = None, + cursor: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[DeliveriesWebhookResponse]: + """ + List recent **failed** webhook delivery attempts for the authenticated API + client, newest first. Use this to debug a misbehaving endpoint and to find + the events you need to replay: successful (2xx) deliveries are never + recorded, so every row here is a delivery your endpoint did not accept. + + Timeouts are first-class failures: `statusCode` is `0` and `error` is + `timeout`. For HTTP error responses, a truncated copy of your server's + response body is included to aid debugging. The request payload is never + stored — to recover the data, re-fetch the underlying resource (e.g. via + `chat.history`) using the delivery's `messageId`/`event` context. + + Results are strictly scoped to the caller's own subscriptions and retained + for roughly 30 days. + + **Access:** Organization and Personal. + + **Required scope:** `webhook:read` + + Parameters + ---------- + webhook : typing.Optional[str] + Only return deliveries for this webhook subscription ID. + + event : typing.Optional[str] + Only return deliveries for this event name (e.g. `chat.message`). + + after : typing.Optional[str] + Only return deliveries after this time (RFC3339 or `YYYY-MM-DD`). Results switch to oldest-first. + + before : typing.Optional[str] + Only return deliveries before this time (RFC3339 or `YYYY-MM-DD`). + + limit : typing.Optional[int] + Maximum number of deliveries to return. + + cursor : typing.Optional[str] + Opaque pagination cursor from a previous response's `nextCursor`. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[DeliveriesWebhookResponse] + Failed delivery attempts for the caller's subscriptions. + """ + _response = await self._client_wrapper.httpx_client.request( + "webhook.deliveries", + method="GET", + params={ + "webhook": webhook, + "event": event, + "after": after, + "before": before, + "limit": limit, + "cursor": cursor, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + DeliveriesWebhookResponse, + parse_obj_as( + type_=DeliveriesWebhookResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 429: + raise TooManyRequestsError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/src/roamhq/webhook/types/__init__.py b/src/roamhq/webhook/types/__init__.py new file mode 100644 index 0000000..4ebd27f --- /dev/null +++ b/src/roamhq/webhook/types/__init__.py @@ -0,0 +1,52 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +from __future__ import annotations + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .deliveries_webhook_response import DeliveriesWebhookResponse + from .deliveries_webhook_response_deliveries_item import DeliveriesWebhookResponseDeliveriesItem + from .list_webhook_response import ListWebhookResponse + from .list_webhook_response_webhooks_item import ListWebhookResponseWebhooksItem + from .webhook_subscription_request_event import WebhookSubscriptionRequestEvent +_dynamic_imports: typing.Dict[str, str] = { + "DeliveriesWebhookResponse": ".deliveries_webhook_response", + "DeliveriesWebhookResponseDeliveriesItem": ".deliveries_webhook_response_deliveries_item", + "ListWebhookResponse": ".list_webhook_response", + "ListWebhookResponseWebhooksItem": ".list_webhook_response_webhooks_item", + "WebhookSubscriptionRequestEvent": ".webhook_subscription_request_event", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = [ + "DeliveriesWebhookResponse", + "DeliveriesWebhookResponseDeliveriesItem", + "ListWebhookResponse", + "ListWebhookResponseWebhooksItem", + "WebhookSubscriptionRequestEvent", +] diff --git a/src/roamhq/webhook/types/deliveries_webhook_response.py b/src/roamhq/webhook/types/deliveries_webhook_response.py new file mode 100644 index 0000000..6b8e161 --- /dev/null +++ b/src/roamhq/webhook/types/deliveries_webhook_response.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from .deliveries_webhook_response_deliveries_item import DeliveriesWebhookResponseDeliveriesItem + + +class DeliveriesWebhookResponse(UniversalBaseModel): + deliveries: typing.List[DeliveriesWebhookResponseDeliveriesItem] = pydantic.Field() + """ + Failed delivery attempts, newest first. Empty array if none. + """ + + next_cursor: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="nextCursor"), + pydantic.Field(alias="nextCursor", description="Opaque cursor for the next page. Omitted on the last page."), + ] = None + """ + Opaque cursor for the next page. Omitted on the last page. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/webhook/types/deliveries_webhook_response_deliveries_item.py b/src/roamhq/webhook/types/deliveries_webhook_response_deliveries_item.py new file mode 100644 index 0000000..a9479e4 --- /dev/null +++ b/src/roamhq/webhook/types/deliveries_webhook_response_deliveries_item.py @@ -0,0 +1,94 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata + + +class DeliveriesWebhookResponseDeliveriesItem(UniversalBaseModel): + timestamp: dt.datetime = pydantic.Field() + """ + When the delivery was attempted (RFC3339 UTC). + """ + + webhook_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="webhookId"), + pydantic.Field( + alias="webhookId", + description="ID of the webhook subscription. Omitted for static (UI-configured) destinations.", + ), + ] = None + """ + ID of the webhook subscription. Omitted for static (UI-configured) destinations. + """ + + event: str = pydantic.Field() + """ + The event name that was being delivered (e.g. `chat.message`). + """ + + url: str = pydantic.Field() + """ + The destination URL that was called. + """ + + status_code: typing_extensions.Annotated[ + int, + FieldMetadata(alias="statusCode"), + pydantic.Field( + alias="statusCode", + description="HTTP status code returned by the destination. `0` for connection errors and timeouts.", + ), + ] + """ + HTTP status code returned by the destination. `0` for connection errors and timeouts. + """ + + error: str = pydantic.Field() + """ + Failure classification (e.g. `timeout`, `http_4xx`, `http_5xx`, `connection`). + """ + + response: typing.Optional[str] = pydantic.Field(default=None) + """ + Truncated response body from the destination, present for HTTP error statuses. + """ + + duration_ms: typing_extensions.Annotated[ + int, + FieldMetadata(alias="durationMs"), + pydantic.Field(alias="durationMs", description="How long the delivery attempt took, in milliseconds."), + ] + """ + How long the delivery attempt took, in milliseconds. + """ + + message_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="messageId"), + pydantic.Field(alias="messageId", description="ID of the message that triggered the event, when applicable."), + ] = None + """ + ID of the message that triggered the event, when applicable. + """ + + success: bool = pydantic.Field() + """ + Always `false` — only failed deliveries are recorded. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/webhook/types/list_webhook_response.py b/src/roamhq/webhook/types/list_webhook_response.py new file mode 100644 index 0000000..9c7782a --- /dev/null +++ b/src/roamhq/webhook/types/list_webhook_response.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .list_webhook_response_webhooks_item import ListWebhookResponseWebhooksItem + + +class ListWebhookResponse(UniversalBaseModel): + webhooks: typing.List[ListWebhookResponseWebhooksItem] = pydantic.Field() + """ + Webhook subscriptions owned by this API client. Empty array if none exist. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/webhook/types/list_webhook_response_webhooks_item.py b/src/roamhq/webhook/types/list_webhook_response_webhooks_item.py new file mode 100644 index 0000000..020e6d8 --- /dev/null +++ b/src/roamhq/webhook/types/list_webhook_response_webhooks_item.py @@ -0,0 +1,94 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import datetime as dt +import typing + +import pydantic +import typing_extensions +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from ...core.serialization import FieldMetadata +from ...types.webhook_subscription_filter import WebhookSubscriptionFilter + + +class ListWebhookResponseWebhooksItem(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique identifier of the webhook subscription. + """ + + event: str = pydantic.Field() + """ + Subscribed event name (e.g. `chat.message`, `chat.reaction`). + """ + + url: str = pydantic.Field() + """ + Destination URL for webhook deliveries. + """ + + filter: typing.Optional[WebhookSubscriptionFilter] = pydantic.Field(default=None) + """ + Event-specific filter applied to the subscription. Omitted if no filter is set. + """ + + dynamic: bool = pydantic.Field() + """ + `true` if the subscription was created via `/webhook.subscribe`. + `false` if it was configured statically in the Roam Administration UI. + """ + + created: typing.Optional[dt.datetime] = pydantic.Field(default=None) + """ + When the subscription was created. + """ + + last_success_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="lastSuccessAt"), + pydantic.Field( + alias="lastSuccessAt", + description="Last terminal 2xx (RFC3339 UTC). Omitted until the\ndestination has succeeded at least once.", + ), + ] = None + """ + Last terminal 2xx (RFC3339 UTC). Omitted until the + destination has succeeded at least once. + """ + + fail_streak_started_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="failStreakStartedAt"), + pydantic.Field( + alias="failStreakStartedAt", + description="Start of the current consecutive-failure span\n(RFC3339 UTC). Omitted when healthy.", + ), + ] = None + """ + Start of the current consecutive-failure span + (RFC3339 UTC). Omitted when healthy. + """ + + disabled_at: typing_extensions.Annotated[ + typing.Optional[dt.datetime], + FieldMetadata(alias="disabledAt"), + pydantic.Field( + alias="disabledAt", + description="When the fail streak reached 24 hours (RFC3339 UTC).\nWhile set the subscription is paused. Omitted when\nactive.", + ), + ] = None + """ + When the fail streak reached 24 hours (RFC3339 UTC). + While set the subscription is paused. Omitted when + active. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/roamhq/webhook/types/webhook_subscription_request_event.py b/src/roamhq/webhook/types/webhook_subscription_request_event.py new file mode 100644 index 0000000..2c2af8c --- /dev/null +++ b/src/roamhq/webhook/types/webhook_subscription_request_event.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations + +import typing + +WebhookSubscriptionRequestEvent = typing.Union[ + typing.Literal[ + "chat.message", + "chat.reaction", + "chat.link.shared", + "lobby.booked", + "magicast.created", + "meeting.started", + "meeting.ended", + "user.status.update", + "onair.event.created", + "onair.event.updated", + "onair.event.canceled", + "onair.guest.rsvp", + "onair.guest.added", + "token.revoked", + "app.uninstalled", + ], + typing.Any, +] diff --git a/tests/README.md b/tests/README.md index 0187dd6..0d12454 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,6 +4,12 @@ Hand-written tests that pin generated client behavior. HTTP is faked with `httpx.MockTransport` against `https://api.ro.am/v1`. An unhandled request fails the test rather than hitting the network. -`webhook_verify_test.py` covers the hand-written verifier and runs against -the seed tree (no generated client required). Pagination / version / retry -tests land once the generated client is installed. +| File | What it pins | +| --- | --- | +| `webhook_verify_test.py` | Hand-written Standard Webhooks verifier (`whsec_` HMAC). | +| `pagination_test.py` | `cursor` query param and `nextCursor` → `next_cursor` on `/group.list`. | +| `version_header_test.py` | `Roam-Version` absent by default, sent when pinned. | +| `retry_test.py` | 429 retried, `Retry-After` honored, 400 not retried. | + +Python list methods return the response body (no auto-pager on this Fern +plan). Callers pass `cursor=` from `next_cursor` themselves. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 8f48e30..5feee8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,87 @@ """HTTP is faked at httpx.MockTransport against https://api.ro.am/v1. -Contract tests that need the generated client skip if it is not installed -(the seed checkout before first regeneration). +An unhandled request fails the test rather than hitting the network — +same idea as msw's onUnhandledRequest: "error". """ + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional +from urllib.parse import urlparse + +import httpx +import pytest + +from roamhq import RoamClient + +BASE_HOST = "api.ro.am" +BASE_PATH_PREFIX = "/v1" +TOKEN = "rmk-test-token" + + +@dataclass +class SeenReq: + method: str + path: str + params: dict[str, str] + headers: httpx.Headers + + +Handler = Callable[[httpx.Request], httpx.Response] + + +@dataclass +class FakeAPI: + """Intercepts at httpx.Client so URL construction, auth, and retries run.""" + + seen: list[SeenReq] = field(default_factory=list) + _handlers: dict[str, Handler] = field(default_factory=dict) + + def handle(self, method: str, path: str, handler: Handler) -> None: + self._handlers[f"{method.upper()} {path}"] = handler + + def _handler(self, request: httpx.Request) -> httpx.Response: + parsed = urlparse(str(request.url)) + if parsed.scheme != "https" or parsed.hostname != BASE_HOST: + pytest.fail(f"unhandled request (wrong host): {request.url}") + path = parsed.path + key = f"{request.method.upper()} {path}" + handler = self._handlers.get(key) + if handler is None: + pytest.fail(f"unhandled request: {request.method} {request.url}") + params = dict(request.url.params) + self.seen.append( + SeenReq( + method=request.method.upper(), + path=path, + params=params, + headers=request.headers, + ) + ) + return handler(request) + + def client(self, **kwargs: object) -> RoamClient: + transport = httpx.MockTransport(self._handler) + httpx_client = httpx.Client(transport=transport) + return RoamClient( + token=TOKEN, + httpx_client=httpx_client, + **kwargs, # type: ignore[arg-type] + ) + + +def json_response( + status: int, + body: object, + headers: Optional[dict[str, str]] = None, +) -> httpx.Response: + hdrs = {"content-type": "application/json"} + if headers: + hdrs.update(headers) + return httpx.Response(status, json=body, headers=hdrs) + + +@pytest.fixture +def api() -> FakeAPI: + return FakeAPI() diff --git a/tests/pagination_test.py b/tests/pagination_test.py new file mode 100644 index 0000000..fcfe174 --- /dev/null +++ b/tests/pagination_test.py @@ -0,0 +1,76 @@ +"""Cursor pagination as a consumer of roamhq. + +Pins the x-fern-pagination modeling in fern/apis/roam/overrides.yml +(developer-ro-am). The spec says `cursor` goes in on the request, the server +hands back `nextCursor`, and the items live under a named array — for +/group.list that array is `groups`. + +Fern's Python generator on this plan does not emit an auto-pager (SyncPager +exists in core/ but list methods return the response body). Callers paginate +by passing `cursor=` from `next_cursor`. If those three names drift, the +generated client silently stops paginating: page one looks complete. +""" + +from __future__ import annotations + +import httpx + +from tests.conftest import FakeAPI, json_response + +PAGE_1 = { + "ok": True, + "groups": [ + {"id": "g1", "name": "Engineering", "type": "standard"}, + {"id": "g2", "name": "Design", "type": "standard"}, + ], + "nextCursor": "cursor-page-2", +} +PAGE_2 = { + "ok": True, + "groups": [ + {"id": "g3", "name": "Support", "type": "standard"}, + ], + "nextCursor": None, +} + + +def _cursor(request: httpx.Request) -> str | None: + return request.url.params.get("cursor") + + +def test_cursor_query_absent_on_first_page(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, PAGE_1)) + page = api.client().group.list() + assert [g.id for g in page.groups] == ["g1", "g2"] + assert page.next_cursor == "cursor-page-2" + assert "cursor" not in api.seen[0].params + + +def test_cursor_from_next_cursor_fetches_second_page(api: FakeAPI) -> None: + def handle(request: httpx.Request) -> httpx.Response: + cursor = _cursor(request) + if cursor is None: + return json_response(200, PAGE_1) + if cursor == "cursor-page-2": + return json_response(200, PAGE_2) + raise AssertionError(f"unexpected cursor: {cursor!r}") + + api.handle("GET", "/v1/group.list", handle) + c = api.client() + page = c.group.list() + assert [g.name for g in page.groups] == ["Engineering", "Design"] + assert page.next_cursor == "cursor-page-2" + + page = c.group.list(cursor=page.next_cursor) + assert [g.id for g in page.groups] == ["g3"] + assert page.next_cursor is None + + assert len(api.seen) == 2 + assert "cursor" not in api.seen[0].params + assert api.seen[1].params.get("cursor") == "cursor-page-2" + + +def test_limit_is_forwarded_as_query(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, PAGE_1)) + api.client().group.list(limit=2) + assert api.seen[0].params.get("limit") == "2" diff --git a/tests/retry_test.py b/tests/retry_test.py new file mode 100644 index 0000000..c78d72b --- /dev/null +++ b/tests/retry_test.py @@ -0,0 +1,102 @@ +"""429 handling and Retry-After. + +Pins the rate-limit modeling in fern/apis/roam/overlays.yml +(developer-ro-am), which attaches a documented 429 response with a +Retry-After header to every operation. + +The contract in docs/guides/sdks.md is specific: a 429 is retried, not +thrown, and the wait comes from Retry-After rather than from the client's +own backoff guess. Those are two separate claims, so this suite checks +the delay value and not just the retry count — exponential backoff would +also produce a second request. + +Timers are real. Fern's Python retrier applies no jitter on the +Retry-After path, so Retry-After: 2 is a two-second sleep. Default +exponential backoff for the first retry is ~1s, so 2s is what separates +"honored the server" from "guessed and got lucky". +""" + +from __future__ import annotations + +import time + +import httpx +import pytest + +from roamhq.errors import BadRequestError, TooManyRequestsError +from tests.conftest import FakeAPI, json_response + +RATELIMITED = {"ok": False, "error": "ratelimited"} +OK_ONE_GROUP = { + "ok": True, + "groups": [{"id": "g1", "name": "Engineering", "type": "standard"}], + "nextCursor": None, +} + + +def always_429(counter: list[int]) -> object: + def handle(_request: httpx.Request) -> httpx.Response: + counter[0] += 1 + return json_response(429, RATELIMITED, {"Retry-After": "2"}) + + return handle + + +def test_retry_after_honors_header_exactly(api: FakeAPI) -> None: + attempts = [0] + + def handle(_request: httpx.Request) -> httpx.Response: + attempts[0] += 1 + if attempts[0] == 1: + return json_response(429, RATELIMITED, {"Retry-After": "2"}) + return json_response(200, OK_ONE_GROUP) + + api.handle("GET", "/v1/group.list", handle) + start = time.monotonic() + page = api.client().group.list() + elapsed = time.monotonic() - start + assert attempts[0] == 2 + assert [g.id for g in page.groups] == ["g1"] + assert 2.0 <= elapsed < 3.0, f"elapsed = {elapsed}s, want ~2s from Retry-After" + + +def test_retry_exhaustion_throws_too_many_requests(api: FakeAPI) -> None: + n = [0] + api.handle("GET", "/v1/group.list", always_429(n)) + with pytest.raises(TooManyRequestsError) as exc: + api.client().group.list() + # Default max_retries=2 is a cap on retries, not total HTTP calls: + # attempt 0 plus two retries = 3 requests. + assert n[0] == 3 + assert exc.value.status_code == 429 + + +def test_max_retries_zero_does_not_retry(api: FakeAPI) -> None: + n = [0] + api.handle("GET", "/v1/group.list", always_429(n)) + with pytest.raises(TooManyRequestsError): + api.client().group.list(request_options={"max_retries": 0}) + assert n[0] == 1 + + +def test_retry_surfaces_parsed_error_envelope(api: FakeAPI) -> None: + n = [0] + api.handle("GET", "/v1/group.list", always_429(n)) + with pytest.raises(TooManyRequestsError) as exc: + api.client().group.list(request_options={"max_retries": 0}) + assert exc.value.status_code == 429 + assert exc.value.body is not None + assert exc.value.body.error == "ratelimited" + + +def test_does_not_retry_400(api: FakeAPI) -> None: + n = [0] + + def handle(_request: httpx.Request) -> httpx.Response: + n[0] += 1 + return json_response(400, {"ok": False, "error": "invalid_arguments"}) + + api.handle("GET", "/v1/group.list", handle) + with pytest.raises(BadRequestError): + api.client().group.list() + assert n[0] == 1 diff --git a/tests/version_header_test.py b/tests/version_header_test.py new file mode 100644 index 0000000..db7775d --- /dev/null +++ b/tests/version_header_test.py @@ -0,0 +1,64 @@ +"""The Roam-Version request header. + +Pins x-fern-global-headers in fern/apis/roam/overrides.yml and the matching +headers: block in fern/apis/roam/generators.yml (developer-ro-am). + +Two halves matter, and the second is the easy one to lose. Sending the +header when the caller asks for a pin is obvious. Not sending it otherwise +is the subtle part: Roam falls back to the version stamped on the +credential when the header is absent, so a client that always sent +something — an empty string, or a baked-in default — would silently +override every integration's pin. +""" + +from __future__ import annotations + +from tests.conftest import TOKEN, FakeAPI, json_response + +PINNED = "2026-07-23" +OTHER = "2026-01-15" + +EMPTY = {"ok": True, "groups": [], "nextCursor": None} + + +def test_roam_version_absent_when_not_pinned(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, EMPTY)) + api.client().group.list() + assert api.seen[0].headers.get("Roam-Version") in (None, "") + + +def test_roam_version_sent_when_set_on_client(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, EMPTY)) + api.client(roam_version=PINNED).group.list() + assert api.seen[0].headers.get("Roam-Version") == PINNED + + +def test_roam_version_sent_when_set_on_request(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, EMPTY)) + api.client().group.list( + request_options={"additional_headers": {"Roam-Version": PINNED}} + ) + assert api.seen[0].headers.get("Roam-Version") == PINNED + + +def test_roam_version_per_request_overrides_client(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, EMPTY)) + api.client(roam_version=PINNED).group.list( + request_options={"additional_headers": {"Roam-Version": OTHER}} + ) + assert api.seen[0].headers.get("Roam-Version") == OTHER + + +def test_roam_version_client_pin_kept_when_not_overridden(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, EMPTY)) + c = api.client(roam_version=PINNED) + c.group.list(request_options={"additional_headers": {"Roam-Version": OTHER}}) + c.group.list() + assert api.seen[0].headers.get("Roam-Version") == OTHER + assert api.seen[1].headers.get("Roam-Version") == PINNED + + +def test_roam_version_still_sends_bearer_auth(api: FakeAPI) -> None: + api.handle("GET", "/v1/group.list", lambda _r: json_response(200, EMPTY)) + api.client(roam_version=PINNED).group.list() + assert api.seen[0].headers.get("Authorization") == f"Bearer {TOKEN}"