Metadata-Version: 2.4
Name: meemo
Version: 0.0.11
Summary: Meemo Python Client - Python client library for Meemo External API
Author-email: GDP Labs <jobs@gdplabs.id>
Requires-Python: <3.14,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: anyio==4.14.2
Requires-Dist: gllm-core<0.5.0,>=0.4.53
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.9.0
Dynamic: license-file

# Meemo

A Python library for interacting with the Meemo External API, providing access to meeting data including details, transcripts, summaries, participants, and recordings.

## Prerequisites

- **Python** >=3.11, <3.14

## Installation

```bash
pip install meemo
```

Or with uv:

```bash
uv add meemo
```

## Quick Start

```python
from meemo import MeemoClient

# Initialize the client
client = MeemoClient(
    client_id="your-client-id",
    client_secret="your-client-secret",
    base_url="https://api-meemo.glair.ai",
)

# List meetings
meetings = client.meetings.list_meetings()
for meeting in meetings.results:
    recording = meeting.recording.status if meeting.recording else "n/a"
    print(f"{meeting.id}: {meeting.title} ({meeting.status}, recording={recording})")

# Get meeting details
detail = client.meetings.get_meeting(123)
print(f"Host: {detail.host.name}")
if detail.duration_seconds is not None:
    print(f"Duration: {detail.duration_seconds}s")
else:
    print("Duration: still in progress")
if detail.recording:
    print(f"Recording: {detail.recording.status} ({detail.recording.description})")

# Get transcript
transcript = client.meetings.get_transcript(123)
for segment in transcript.transcripts:
    print(f"[{segment.speaker}] {segment.text}")

# Get summary
summary = client.meetings.get_summary(123)
print(summary.summary)

# Get summary with seekable video timestamps (for messaging UIs)
rich = client.meetings.get_summary_with_video(123)
print(rich.inline_message_markdown)  # for AIP agents; send verbatim
```

## Async Usage

For use in async frameworks (FastAPI, Django async views, async workers), use `AsyncMeemoClient`:

```python
import asyncio
from meemo import AsyncMeemoClient

async def main():
    client = AsyncMeemoClient(
        client_id="your-client-id",
        client_secret="your-client-secret",
        base_url="https://api-meemo.glair.ai",
    )

    # All methods are async — same API surface as MeemoClient
    meetings = await client.meetings.list_meetings()
    for meeting in meetings.results:
        print(f"{meeting.id}: {meeting.title}")

    detail = await client.meetings.get_meeting(123)
    transcript = await client.meetings.get_transcript(123)
    summary = await client.meetings.get_summary(123)

    # Revoke token when done
    await client.revoke_token()

asyncio.run(main())
```

## Configuration

### Environment Variables

The library supports the following environment variables:

- `MEEMO_CLIENT_ID`: OAuth2 client ID for authentication
- `MEEMO_CLIENT_SECRET`: OAuth2 client secret for authentication
- `MEEMO_BASE_URL`: Base URL of the Meemo instance

A client built with `access_token` does not read `MEEMO_CLIENT_ID` or `MEEMO_CLIENT_SECRET` (see [User Tokens (OAuth)](#user-tokens-oauth)).

### Client Initialization

```python
from meemo import MeemoClient

# Using explicit parameters
client = MeemoClient(
    client_id="your-client-id",
    client_secret="your-client-secret",
    base_url="https://api-meemo.glair.ai",
    timeout=60.0,
    default_headers={"X-Custom-Header": "value"},
)

# Using environment variables
import os
os.environ["MEEMO_CLIENT_ID"] = "your-client-id"
os.environ["MEEMO_CLIENT_SECRET"] = "your-client-secret"
os.environ["MEEMO_BASE_URL"] = "https://api-meemo.glair.ai"
client = MeemoClient()
```

## Authentication

The library uses **OAuth2 Client Credentials** flow. Tokens are automatically obtained and refreshed when they expire (default: 3 hours).

```python
# Token management is automatic, but you can also manage it manually:

# Revoke the current token
client.revoke_token()
```

## User Tokens (OAuth)

A client built from a user's Meemo access token calls the API as that user, so it sees only the meetings that user can read. Meemo is the OAuth provider: the user signs in with Google or Microsoft through redirects only (authorization code + PKCE S256). The OAuth client must be registered in Meemo as a confidential, authorization-code external application.

```python
import base64
import hashlib
import secrets

from meemo import MeemoClient, MeemoTokenExpiredError
from meemo.oauth import build_authorize_url, exchange_code, refresh_token, revoke_token

BASE_URL = "https://api-meemo.glair.ai"
CLIENT = {"client_id": "your-client-id", "client_secret": "your-client-secret"}
REDIRECT_URI = "https://your-app.example.com/callback"

# 1. Make a PKCE pair and a state for this sign-in; keep code_verifier and state for the redirect back
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(32)

# Send the user's browser here. Meemo redirects back to REDIRECT_URI with ?code=...&state=...
url = build_authorize_url(
    base_url=BASE_URL,
    client_id=CLIENT["client_id"],
    redirect_uri=REDIRECT_URI,
    code_challenge=code_challenge,
    state=state,
    idp="google",  # or "microsoft"
)

# 2. On the redirect back, check state, then exchange the code for the user's tokens
tokens = exchange_code(
    base_url=BASE_URL,
    **CLIENT,
    code=code,
    code_verifier=code_verifier,
    redirect_uri=REDIRECT_URI,
    external_org_id="your-platform-org-id",  # your platform's stable ID for the user's organization
)

# 3. Call the API as the user
client = MeemoClient(base_url=BASE_URL, access_token=tokens.access_token)
try:
    meetings = client.meetings.list_meetings()
except MeemoTokenExpiredError:
    # 4. Renew; Meemo rotates the refresh token, so store the new one
    tokens = refresh_token(base_url=BASE_URL, **CLIENT, refresh_token=tokens.refresh_token)
    save_refresh_token(tokens.refresh_token)  # your storage; the old refresh token no longer works
    client = MeemoClient(base_url=BASE_URL, access_token=tokens.access_token)
    meetings = client.meetings.list_meetings()

# 5. When the user disconnects, revoke their refresh token; Meemo revokes its access token with it
revoke_token(base_url=BASE_URL, **CLIENT, token=tokens.refresh_token)
```

- A user-token client never uses client credentials, and `client.revoke_token()` raises `MeemoInvalidArgumentError`; revoke with `meemo.oauth.revoke_token`.
- An HTTP 401 for an expired token raises `MeemoTokenExpiredError` (a `MeemoAuthError`), so the caller refreshes the token. Any other 401 (an unknown, revoked or malformed token) stays a plain `MeemoAuthError`, since refreshing cannot fix it; send the user through sign-in again. An HTTP 403 stays a plain `MeemoAuthError`: the token is valid but may not call that endpoint.
- A refused code or refresh token raises `MeemoValidationError` with `api_code == "invalid_grant"`; send the user through sign-in again.
- The async helpers are in `meemo.async_oauth` and take the same arguments; `AsyncMeemoClient(base_url=..., access_token=...)` works the same way.

## Meetings API

### List Meetings

```python
# Get all meetings
meetings = client.meetings.list_meetings()
print(f"Total: {meetings.count}")

# Filter by organization
meetings = client.meetings.list_meetings(organization_id=5)

# Filter by title and date
meetings = client.meetings.list_meetings(
    title="standup",
    created_after="2024-01-01",
    created_before="2024-12-31",
    summary_complete=True,
)

# Pagination
meetings = client.meetings.list_meetings(page=2, size=20)

# Resolve a Google Calendar event id to a meeting (None until auto-join creates it)
meeting = client.meetings.find_meeting_by_calendar_event_id(
    "google_event_abc123",
    organization_id=5,
)
if meeting:
    print(meeting.id, meeting.title)
```

`find_meeting_by_calendar_event_id` returns a `MeetingListItem` (the list row,
not the full detail) or `None`, and verifies the returned row's
`calendar_event.event_id` before handing it back, so it answers `None` rather
than an unrelated meeting when talking to an api-service build that predates the
`calendar_event_id` filter. Call `get_meeting(meeting.id)` for full metadata.

All filters are keyword-only (see `MeetingListKwargs` for the accepted keys);
`extra_headers` remains the only positional argument.

#### Search by Summary

`search` runs a ranked lexical search over the meeting title, calendar invite
summary, the AI-generated summary markdown (the `summary` field from
`get_summary` / `summary_markdown` on `GET .../summary/` — **not** `notes`), and
tags — so you can find a meeting by what was discussed in it, not just by its
title. Results come back ordered by relevance instead of start time.

```python
# Find meetings whose summary (or title/tags) mentions the query
meetings = client.meetings.list_meetings(search="pricing model")
for m in meetings.results:
    print(f"{m.id}: {m.title}")

# Combine with any other filter (all active filters use AND semantics)
meetings = client.meetings.list_meetings(
    search="pricing model",
    organization_id=5,
    start_after="2024-01-01",
)
```

Ranking is tiered: title and calendar-summary hits rank above summary-only
hits, which rank above tag-only hits. Summary matching is a contiguous
substring match, so `"budget Q3"` will not match a summary reading
`"Q3 budget"` — title and calendar matching does handle non-adjacent words.

#### Filter by meeting link, calendar name, or description

These filters are case-insensitive contains matches against calendar metadata
(and, for `meeting_link`, also `bot_meeting_url`). They can return many rows;
combine them with other filters using AND semantics. A present-but-blank value
matches nothing.

Requires an api-service build that carries the calendar lookup filters (Meemo
`beta`). An older build drops them as unknown query params and answers with an
unfiltered list, which is indistinguishable from "everything matched" — unlike
`find_meeting_by_calendar_event_id`, these filters do not re-check the returned
rows for you. To probe a build, query a value that cannot exist (a random UUID):
a build that carries the filter answers with an empty page.

```python
# By conference URL (full URL or a meet code such as "abc-defg-hij")
meetings = client.meetings.list_meetings(
    meeting_link="https://meet.google.com/abc-defg-hij",
)

# By calendar event name/summary
meetings = client.meetings.list_meetings(calendar_event_name="Q1 Planning")

# By calendar event description
meetings = client.meetings.list_meetings(calendar_event_description="budget review")
```

A query longer than the stored link (for example the same Meet URL with
`?authuser=0` appended) matches nothing. A generic substring such as
`meet.google.com` can match many meetings.

### Get Meeting Details

```python
detail = client.meetings.get_meeting(123)
print(f"Title: {detail.title}")
print(f"Host: {detail.host.name} ({detail.host.email})")
print(f"Location: {detail.location}")
print(f"Language: {detail.language}")
print(f"Keywords: {', '.join(detail.keywords)}")
print(f"Participants: {detail.participant_count}")
if detail.duration_seconds is not None:
    print(f"Duration: {detail.duration_seconds}s")
else:
    print("Duration: still in progress (end_time is null until the meeting ends)")

if detail.recording:
    print(f"Recording: {detail.recording.status} — {detail.recording.description}")
if detail.transcription:
    print(f"Transcription: {detail.transcription.status} — {detail.transcription.description}")
if detail.summarization:
    print(f"Summarization: {detail.summarization.status} — {detail.summarization.description}")

# Calendar event info (if from calendar integration)
if detail.calendar_event:
    print(f"Calendar: {detail.calendar_event.summary}")
    print(f"Meeting Link: {detail.calendar_event.meeting_link}")
    print(f"Scheduled end: {detail.calendar_event.end_time}")
```

`end_time` and `duration_seconds` stay `None` until the meeting actually ends. For bot meetings, `start_time` is when the bot joined and `end_time` is when it left or failed. The calendar invite's scheduled end is on `calendar_event.end_time`, not the meeting's `end_time`.

`recording`, `transcription`, and `summarization` each have a `status` and a readable `description`. For bot meetings, `recording` follows the bot (pending while joining or waiting, in progress in the call, completed after it leaves). They are optional so older api-service builds that omit them still parse.

These are **processing progress, not the media**: `detail.recording.status == "completed"` says the recording finished, not where to download it — use [`get_recording()`](#get-meeting-recording) for the file itself.

Compare a `status` against the exported `StageStatus` enum rather than a bare string:

```python
from meemo import StageStatus

if detail.transcription and detail.transcription.status == StageStatus.FAILED:
    print(f"Transcription failed: {detail.transcription.description}")
```

`StageStatus` is a `StrEnum` with `PENDING`, `IN_PROGRESS`, `COMPLETED`, `FAILED`, and `CANCELLED`, so it compares equal to the raw strings the API sends. All three stages report `CANCELLED` when a scheduled bot join was cancelled before the bot was sent.

### Get Meeting Transcript

```python
transcript = client.meetings.get_transcript(123)
print(f"Total segments: {transcript.total_segments}")

for segment in transcript.transcripts:
    minutes = int(segment.start_time // 60)
    seconds = int(segment.start_time % 60)
    print(f"[{minutes:02d}:{seconds:02d}] {segment.speaker}: {segment.text}")
```

### Get Meeting Summary

```python
summary = client.meetings.get_summary(123)
print(summary.summary)  # Markdown or legacy JSON
print(f"Notes: {summary.notes}")
print(f"Keywords: {', '.join(summary.keywords)}")

# Handle both new Markdown and legacy JSON formats
if isinstance(summary.summary, str):
    # New Markdown format
    print(summary.summary)
elif isinstance(summary.summary, dict):
    # Legacy JSON format
    print(summary.summary.get("ringkasan", ""))
```

### Get Meeting Summary With Video

Converts the Meemo `(segment:id)` citations in a summary into video timestamp
references for messaging UIs (e.g. GLChat). For raw summary text and keywords,
use `get_summary`.

GLChat has **two mutually exclusive contracts**. Send the field that matches who
is sending the message:

| Sender | Field to send | `media_mapping` |
|--------|---------------|-----------------|
| AIP agent | `inline_message_markdown` (verbatim) | ignore it |
| Native GLChat pipeline | `placeholder_message_markdown` | send it |

An agent cannot supply a `media_mapping`: GLChat derives one from the agent's own
text and mints keys the agent cannot predict, so bracket references coming from
an agent resolve to nothing and are dropped **without an error**. That is why the
agent shape inlines the media markdown per citation instead.

On multi-record meetings the summary here can differ from `get_summary()`, which
always reads the first record. Check `record_id` to see which recording the
citations and video URL are measured against.

```python
rich = client.meetings.get_summary_with_video(123)

print(rich.record_id)  # e.g. 42 — which recording this export describes
print(rich.video_id)  # "video_meeting_123"
print(rich.has_video)        # True when the meeting has a recording (either contract)

# What an AIP agent sends, character for character.
print(rich.inline_message_markdown)

# What a native GLChat pipeline sends, paired with media_mapping.
print(rich.placeholder_message_markdown)
print(rich.media_mapping)

# Resolved citations, unique per distinct set of segment ids.
for citation in rich.citations:
    print(citation.segment_ids, citation.placeholder)

# Convenience: the bare recording URL, parsed out of media_mapping.
# Can be None even when has_video is True, so null-check it before dereferencing.
print(rich.video_url)
```

`has_video` answers "does a recording exist under either contract"; `video_url`
answers "did the pipeline mapping resolve to a URL the SDK could parse". A
`media_mapping` keyed for another `video_id`, or whose value is not `![](...)`
markdown, leaves `has_video` `True` with `video_url` unset.

Both message shapes lead with the video player and are followed by the summary
body. Exactly one player is emitted — a second would register under the same
element key in GLChat and leave the timestamp buttons seeking the wrong video.

Media URLs are signed and time-limited, and are regenerated on every call, so
fetch this close to when the message is actually sent. When the meeting has no
recording, citations degrade to plain labels rather than dead references.

### Get Meeting Participants

```python
participants = client.meetings.get_participants(123)
print(f"Total: {participants.total_participants}")

for p in participants.participants:
    if p.id:
        print(f"  {p.name} ({p.email}) - {p.position}, {p.department}")
    else:
        print(f"  {p.name} (external guest)")
```

### Get Meeting Recording

```python
recording = client.meetings.get_recording(123)

# Preferred: iterate over assets (audio and/or video).
if recording.assets:
    for asset in recording.assets:
        print(f"{asset.media_kind}: {asset.url} ({asset.format})")
    print(f"Duration: {recording.duration}s")
else:
    print("No recording available")

# Deprecated back-compat aliases (audio only), populated from the first
# audio asset when not provided directly:
if recording.recording_url:
    print(f"Audio URL: {recording.recording_url} ({recording.format})")

# Convenience aliases for the first video asset when present:
if recording.video_url:
    print(f"Video URL: {recording.video_url} ({recording.video_format})")
```

### Update Meeting

Renames a meeting and replaces its participants. Either field can be sent alone, or both together; the call returns the updated `MeetingDetail`.

```python
# Title only
meeting = client.meetings.update_meeting(123, title="Q1 planning")

# Participants only: this is the complete list, not an addition
meeting = client.meetings.update_meeting(123, participants=["a@example.com", "b@example.com"])

# Both
meeting = client.meetings.update_meeting(
    123, title="Q1 planning", participants=["a@example.com"]
)
print(meeting.title, meeting.participant_count)

# Remove every participant
client.meetings.update_meeting(123, participants=[])

# Async
meeting = await client.meetings.update_meeting(123, title="Q1 planning")
```

- A field left out, or `None`, stays as it is. Calling with neither sends an empty body: nothing changes, but the call still needs write access and moves `modified_at`.
- `participants` replaces the whole list. `[]` (or `()`) clears it. The SDK only checks that it is a list or tuple of strings and sends the emails in the order, letter case and repeats you gave; Meemo validates them, and counts repeated emails once in any letter case.
- An email of exactly one active member of the meeting's organization adds that member. **Any other email adds a guest**, reusing the meeting's guest with that email or creating one, so a well-formed typo succeeds. Check the spelling before you send.
- People who stay keep their status. People who are removed can lose access they had only as a participant, and their speaker labels on the meeting's first recording are cleared. The transcript text stays.
- The title can be up to 255 characters and cannot be blank. Meemo checks it.

#### Access, errors, and availability

- **All or nothing.** If anything is refused, the call raises `MeemoValidationError` (HTTP 400) and neither the title nor the participants change. Meemo refuses a blank or too long title, a malformed or too long email, an email that matches several members (`validation.participant_ambiguous`), and an email that matches several guests of the meeting (`validation.guest_ambiguous`).
- **Finding the refused email.** `e.params["fields"]["participants"]` maps the position of every refused email in the list you sent, as a string, to its messages, and `e.params["fields"]["title"]` holds the title's. `e.api_code` and `e.message` describe only the first refusal.
- **Repeating.** Sending the same list again does not add participants. Permissions and concurrent changes can differ between attempts, though, so a repeat can raise `MeemoNotFoundError` or `MeemoAuthError`. The SDK does not retry for you, and a timeout after the request was sent is marked not retryable: read the meeting before you send it again.
- **Write scope.** The token needs the `write` scope (client credentials) or the `external` scope (user-bound). A read-only token gets `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Access.** Client credentials can act on any meeting of the application's organizations. A user-bound token follows the Meemo web app's rule: the meeting's owner, its collaborators and Django staff can edit it, while an organization admin, a participant, or someone with view-only access can read the meeting but gets `MeemoAuthError` (HTTP 403, `permission_denied`). A meeting the caller cannot read raises `MeemoNotFoundError`. User-bound tokens are not supported by this SDK yet ([meemo#1781](https://github.com/GDP-ADMIN/meemo/issues/1781)).
- **Server version.** This endpoint needs a Meemo backend with [meemo#1772](https://github.com/GDP-ADMIN/meemo/issues/1772) (PR [#1912](https://github.com/GDP-ADMIN/meemo/pull/1912)). On an older backend the meeting route does not accept `PATCH`, so expect a `MeemoAPIError` for an unsupported method (most likely HTTP 405); this has not been tested against an older backend.

```python
from meemo import MeemoValidationError

emails = ["a@example.com", "not-an-email"]
try:
    client.meetings.update_meeting(123, title="Q1 planning", participants=emails)
except MeemoValidationError as e:
    refused = ((e.params or {}).get("fields") or {}).get("participants") or {}
    for position, messages in refused.items():
        print(f"{emails[int(position)]}: {' '.join(messages)}")
```

### Delete Meeting

```python
client.meetings.delete_meeting(123)  # None on 204

# Async
await client.meetings.delete_meeting(123)
```

- If the meeting's bot is in the call, it is removed first. The removal is asynchronous, so the bot can take a moment to actually leave.
- Recordings stay under the server's retention policy.
- Deleting a meeting that is already gone raises `MeemoNotFoundError`. A timeout on `delete_meeting` is marked retryable, and if the retry raises `MeemoNotFoundError`, the first attempt most likely deleted the meeting.

### Cancel Meeting Bot

Stops the meeting's bot and keeps the meeting, for example when an interview is rescheduled or cancelled.

```python
client.meetings.cancel_bot(123)  # None on 204

# Async
await client.meetings.cancel_bot(123)
```

| Meeting state | Result |
|---------------|--------|
| Bot scheduled to join, not yet sent | The join is cancelled and no bot is sent. `recording`, `transcription`, and `summarization` report `StageStatus.CANCELLED`. |
| Bot in the call | The bot is asked to leave. `None` means the leave request was accepted or the bot is already gone; it can take a moment to actually leave, and its status and recording keep updating until it does. |
| Bot already cancelled or already left | `None` again, with no change. Repeating the call is safe. |
| No bot and no scheduled join | `MeemoNotFoundError` |

#### Access, errors, and availability

- **Write scope.** The token needs the `write` scope (client credentials) or the `external` scope (user-bound). A read-only token gets `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Access.** Client credentials can act on any meeting of the application's organizations. A user-bound token follows the Meemo web app's rule: the meeting's owner and collaborators can delete it or cancel its bot, while an organization admin, a participant, or someone with view-only access can read the meeting but gets `MeemoAuthError` (HTTP 403, `permission_denied`). User-bound tokens are not supported by this SDK yet ([meemo#1781](https://github.com/GDP-ADMIN/meemo/issues/1781)).
- **404s look alike.** A missing meeting, a meeting the caller cannot read, and (for `cancel_bot`) a meeting with no bot or scheduled join all raise `MeemoNotFoundError` with `api_code == "not_found"`. The code does not tell them apart. Both 403 cases share `permission_denied` too; only `e.message` differs.
- **Bot removal can fail.** If the bot cannot be removed and is not known to have left, both methods raise `MeemoServerError` with `api_code == "bot.remove_failed"` and `e.recovery["retryable"]` set to `True`. The meeting is kept. The SDK does not retry for you.
- **Server version.** These endpoints need a Meemo backend with [meemo#1770](https://github.com/GDP-ADMIN/meemo/issues/1770) (PR [#1884](https://github.com/GDP-ADMIN/meemo/pull/1884)). On an older backend, `delete_meeting` raises `MeemoRequestError` (HTTP 405), and `cancel_bot` raises `MeemoNotFoundError` because the route does not exist, which looks the same as a meeting with no bot.

```python
from meemo import MeemoAuthError, MeemoNotFoundError, MeemoServerError

try:
    client.meetings.cancel_bot(123)
except MeemoAuthError as e:
    print(f"Not allowed to change this meeting: {e.message}")
except MeemoNotFoundError:
    print("No such meeting, no read access to it, or no bot to cancel")
except MeemoServerError as e:
    if e.api_code != "bot.remove_failed":
        raise
    print(f"Bot not removed, meeting kept; retry later (request {e.request_id})")
```

### Share Meeting

Gives members of the meeting's organization access to it.

```python
from meemo import SharingType

# View-only is the default
grants = client.meetings.share_meeting(123, ["member@example.com"])

# Let the member change the meeting and manage its shares
grants = client.meetings.share_meeting(
    123,
    ["member@example.com"],
    sharing_type=SharingType.COLLABORATOR,
)
for grant in grants:
    print(grant.user.id, grant.user.display_name, grant.user.email, grant.sharing_type)

# Async
grants = await client.meetings.share_meeting(123, ["member@example.com"])
```

| `SharingType` | Sent as | Access |
|---------------|---------|--------|
| `VIEW_ONLY` (default) | `"VIEW_ONLY"` | Can read the meeting. |
| `COLLABORATOR` | `"COLLABS"` | Can also change the meeting and manage its shares. |

- Each email must belong to an active member of the meeting's organization. Meemo matches it to the member's account or identity email in any letter case. The SDK only checks that `emails` is a non-empty list of strings; Meemo decides whether each email is valid and who it belongs to.
- Sharing with the meeting's owner, or with the user of your own token, is refused: they already manage the meeting.
- Sharing again with a member who already has a share updates its type. Every other share is kept.
- The result has one `SharedTo` per email, in the order you sent them, repeated emails included. `user.email` is the member's account email, which can differ from the email you sent when that was an alias, so match results to your input by position, not by comparing emails.

### Unshare Meeting

Removes the shares of the given emails and keeps every other share.

```python
client.meetings.unshare_meeting(123, ["member@example.com"])  # None on 204

# Async
await client.meetings.unshare_meeting(123, ["member@example.com"])
```

- Only the share is removed. Access the member has another way, such as an owner or a participant, stays.
- A share is removed even when its member has since left the organization.
- An email of a member who has no share changes nothing and is not an error.

#### Access, errors, and availability

- **All or nothing.** If any email is refused, the call raises `MeemoValidationError` (HTTP 400) and no share changes. Share refuses an email that matches no active member (`validation.recipient_not_found`), matches several members (`validation.recipient_ambiguous`), or is the owner's (`validation.recipient_is_owner`) or your own (`validation.recipient_is_caller`). Unshare refuses only an email that matches no member or several.
- **Finding the refused email.** `e.params["fields"]["emails"]` maps the position of every refused email in the list you sent, as a string, to its messages. `e.api_code` and `e.message` describe only the first refused email.
- **Repeating is safe.** Share updates the same shares and unshare ignores a share that is already gone, so repeating after a timeout is fine. Permissions can change between attempts, though: a repeat can raise `MeemoNotFoundError` or `MeemoAuthError` where the first attempt would have succeeded, for example after a collaborator removed their own share. The SDK does not retry for you, and a timeout after the request was sent is marked not retryable.
- **Write scope.** The token needs the `write` scope (client credentials) or the `external` scope (user-bound). A read-only token gets `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Access.** Client credentials can act on any meeting of the application's organizations. A user-bound token follows the Meemo web app's rule: the meeting's owner and collaborators can share and unshare, while an organization admin, a participant, or someone with view-only access can read the meeting but gets `MeemoAuthError`. A meeting the caller cannot read raises `MeemoNotFoundError`. User-bound tokens are not supported by this SDK yet ([meemo#1781](https://github.com/GDP-ADMIN/meemo/issues/1781)).
- **Server version.** These endpoints need a Meemo backend with [meemo#1771](https://github.com/GDP-ADMIN/meemo/issues/1771) (PR [#1905](https://github.com/GDP-ADMIN/meemo/pull/1905)). On an older backend the routes do not exist, so both methods raise `MeemoNotFoundError`, which looks the same as a missing meeting.

```python
from meemo import MeemoValidationError

emails = ["member@example.com", "typo@example.com"]
try:
    client.meetings.share_meeting(123, emails)
except MeemoValidationError as e:
    refused = ((e.params or {}).get("fields") or {}).get("emails") or {}
    for position, messages in refused.items():
        print(f"{emails[int(position)]}: {' '.join(messages)}")
```

## Multi-Organization Support

External applications can access meetings from multiple organizations with a single set of credentials.

```python
# Get meetings from all accessible organizations
all_meetings = client.meetings.list_meetings()

# Filter to a specific organization
org_meetings = client.meetings.list_meetings(organization_id=5)
```

## Error Handling

API and transport failures raise typed `MeemoError` subclasses (also
`httpx.HTTPStatusError` / `ValueError` / `TypeError` where that matched before).
`e.message` / `str(e)` is Meemo's message (no request URL). Meemo's own code is
on `e.api_code`.

```python
from meemo import (
    MeemoAPIError,
    MeemoAuthError,
    MeemoClient,
    MeemoInvalidArgumentError,
    MeemoNotFoundError,
)

client = MeemoClient(
    client_id="your-client-id",
    client_secret="your-client-secret",
    base_url="https://api-meemo.glair.ai",
)

try:
    detail = client.meetings.get_meeting(999)
except MeemoAuthError as e:
    print("Authentication failed - check your credentials")
    print(e.api_code, e.request_id)
except MeemoNotFoundError as e:
    print(e.message, e.api_code, e.request_id)
except MeemoAPIError as e:
    print(e.message, e.api_code, e.request_id, e.recovery)
except MeemoInvalidArgumentError as e:
    print(f"Invalid argument: {e}")
```

## Testing

### Unit tests

```bash
cd libs/meemo
uv sync --group dev
uv run pytest tests/unit_tests/ -v
```

### Integration tests (live)

The integration tests exercise a real Meemo instance. Copy `.env.example` to `.env`, fill in your Meemo OAuth credentials, then:

```bash
uv run pytest tests/integration_tests/ -v
```

Optional flags in `.env` or the shell:

- `MEEMO_TEST_CREATE_MEETING=1` — also run the tests that create real meetings (off by default): sync and async create/delete round-trips, and sync and async bots scheduled an hour ahead that are cancelled twice and then deleted. They need a backend with the delete and cancel-bot endpoints ([meemo#1770](https://github.com/GDP-ADMIN/meemo/issues/1770), PR [#1884](https://github.com/GDP-ADMIN/meemo/pull/1884)) and fail on an older one. Run them only against a test environment. Each test deletes what it created; if that delete fails, teardown fails with the meeting ID so you can remove it by hand.
- `MEEMO_TEST_SHARE_EMAIL` — with `MEEMO_TEST_CREATE_MEETING=1`, also run the sync and async share/unshare and update round-trips. Set it to the email of an active member of the organization the test meeting is created in (the first one `list_available_organizations` returns) who does not own the meeting. Each test changes a meeting it created, so no existing meeting is touched, and deletes the meeting afterwards. The share tests need a backend with the sharing endpoints ([meemo#1771](https://github.com/GDP-ADMIN/meemo/issues/1771), PR [#1905](https://github.com/GDP-ADMIN/meemo/pull/1905)), and the update tests need [meemo#1772](https://github.com/GDP-ADMIN/meemo/issues/1772) (PR [#1912](https://github.com/GDP-ADMIN/meemo/pull/1912)); each fails on an older backend. The update tests first resolve that email with `share_meeting`, which refuses an email that matches no member, so a mistyped address fails before `update_meeting` can add it as a guest identity that cannot be deleted. Unset, they are skipped.

## API Reference

- [Meemo External API Documentation](https://gdplabs.gitbook.io/meemo/resources/external-api-documentation)

## License

MIT License.
