Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,9 @@ and Rename Room.
| `chat.user.{account}.request.orgs.{orgID}.{siteID}.members` | [List Org Members](#list-org-members) |
| `chat.user.{account}.request.room.{roomID}.{siteID}.app.tabs` | [Get Room App Tabs](#get-room-app-tabs) |
| `chat.user.{account}.request.room.{roomID}.{siteID}.app.cmd-menu` | [Get Room App Command Menu](#get-room-app-command-menu) |
| `chat.user.{account}.request.room.{roomID}.{siteID}.teams.call` | [Start Teams Room Call](#start-teams-room-call) |
| `chat.user.{account}.request.teams.{siteID}.call.user` | [Start Teams User Call](#start-teams-user-call) |
| `chat.user.{account}.request.room.{roomID}.{siteID}.teams.meeting` | [Start Teams Meeting](#start-teams-meeting) |

#### Create Room

Expand Down Expand Up @@ -1804,6 +1807,170 @@ Same envelope and sentinels as Get Room App Tabs.

---

> **Note on `External client label`:** each Teams RPC below lists an HTTP-style
> label (e.g. `POST /api/v1/calls/room`). That label is the path the **edge
> gateway exposes to external/mobile clients**; the gateway translates it to the
> NATS RPC shown under **Subject**. This service implements **only** the NATS RPC
> (request/reply over `_INBOX.>`) — it does not serve an HTTP endpoint.

#### Start Teams Room Call

Builds a Microsoft Teams deep link for a call to every other member of the room (the caller is excluded). No Graph API call — the link is built from the member list, deriving each member's email as `account@TEAMS_EMAIL_DOMAIN`.

External client label: `POST /api/v1/calls/room`.

**Subject:** `chat.user.{account}.request.room.{roomID}.{siteID}.teams.call`
**Reply subject:** auto-generated `_INBOX.>` (NATS request/reply)

- `{siteID}` must be the room's origin `siteID`.
- The requester account is taken from the subject, not from a token.

##### Request body

Empty body is accepted (the room is the subject's `{roomID}`).

| Field | Type | Required | Notes |
|---|---|---|---|
| `roomId` | string | no | Optional echo of the room; the authoritative room is the subject's `{roomID}`. |

##### Success response

| Field | Type | Notes |
|---|---|---|
| `joinUrl` | string | A `https://teams.microsoft.com/l/call/0/0?users=<comma-joined emails>` deep link. |

```json
{ "joinUrl": "https://teams.microsoft.com/l/call/0/0?users=bob%40corp.com%2Ccarol%40corp.com" }
```

##### Error response

See [Error envelope](#6-error-envelope-reference).

| Reason | Code | When |
|---|---|---|
| — | `unauthenticated` | Requester account missing from the subject. |
| — | `bad_request` | `roomId` empty (subject malformed). |
| `not_room_member` | `forbidden` | Caller is not a member of the room. |
| `target_not_member` | `not_found` | No other callable members in the room. |
| `max_room_size_reached` | `conflict` | More than `ROOM_MEMBERS_CALL_LIMIT` (20) other members. |

##### Triggered events — success path

`None — reply only.`

##### Triggered events — error path

`None — error returned only via the reply subject.`

---

#### Start Teams User Call

Builds a Microsoft Teams 1:1 call deep link for a single target account. No Graph API call. The target email is derived as `accountName@TEAMS_EMAIL_DOMAIN`.

External client label: `POST /api/v1/calls/user`.

**Subject:** `chat.user.{account}.request.teams.{siteID}.call.user`
**Reply subject:** auto-generated `_INBOX.>` (NATS request/reply)

- The requester account is taken from the subject, not from a token.

##### Request body

| Field | Type | Required | Notes |
|---|---|---|---|
| `accountName` | string | yes | The target user's account. |

```json
{ "accountName": "bob" }
```

##### Success response

| Field | Type | Notes |
|---|---|---|
| `joinUrl` | string | A `https://teams.microsoft.com/l/call/0/0?users=<email>` deep link. |

```json
{ "joinUrl": "https://teams.microsoft.com/l/call/0/0?users=bob%40corp.com" }
```

##### Error response

See [Error envelope](#6-error-envelope-reference).

| Reason | Code | When |
|---|---|---|
| — | `unauthenticated` | Requester account missing from the subject. |
| — | `bad_request` | `accountName` empty. |

##### Triggered events — success path

`None — reply only.`

##### Triggered events — error path

`None — error returned only via the reply subject.`

---

#### Start Teams Meeting

Creates a Microsoft Teams `onlineMeeting` via the Graph API and returns its join URL. **Idempotent per room, including under concurrency:** the meeting is created via Graph's `createOrGet` endpoint keyed on a stable per-room `externalId`, and a first-class `teams_meetings` record with a unique key on `(roomId, siteId)` guards local state. Repeated or concurrent calls for the same room return the same meeting and publish exactly one `teams_meet_started` system message. Attendee emails are derived as `account@TEAMS_EMAIL_DOMAIN`.

> Graph client details (config env vars, app-only auth, the `createOrGet` idempotency key, the production application-access-policy requirement, and how to test without real credentials) are documented in [`docs/msgraph-client.md`](msgraph-client.md).

External client label: `POST /api/v1/meetings`.

**Subject:** `chat.user.{account}.request.room.{roomID}.{siteID}.teams.meeting`
**Reply subject:** auto-generated `_INBOX.>` (NATS request/reply)

- `{siteID}` must be the room's origin `siteID`.
- The requester account is taken from the subject, not from a token; it becomes the meeting organizer.

##### Request body

Empty body is accepted (the room is the subject's `{roomID}`).

| Field | Type | Required | Notes |
|---|---|---|---|
| `roomId` | string | no | Optional echo of the room; the authoritative room is the subject's `{roomID}`. |

##### Success response

| Field | Type | Notes |
|---|---|---|
| `id` | string | The Graph `onlineMeeting` ID. |
| `joinUrl` | string | The meeting's join web URL. |

```json
{ "id": "MSpkYzE3...", "joinUrl": "https://teams.microsoft.com/l/meetup-join/..." }
```

##### Error response

See [Error envelope](#6-error-envelope-reference).

| Reason | Code | When |
|---|---|---|
| — | `unauthenticated` | Requester account missing from the subject. |
| — | `bad_request` | `roomId` empty (subject malformed). |
| `not_room_member` | `forbidden` | Caller is not a member of the room. |
| `max_room_size_reached` | `conflict` | Room has more than `ROOM_MEMBERS_LIMIT` (500) members. |
| — | `internal` | Teams meetings not configured, or the Graph create failed. |

##### Triggered events — success path

On first creation, a `teams_meet_started` system message is published on the canonical message path (`chat.msg.canonical.{siteID}.created`), persisted by `message-worker`, and fanned out to room members like other system messages. Its `sysMsgData` carries `{ "meetingId": "...", "joinUrl": "..." }`.
On idempotent repeat calls that return cached meeting details, no additional system message is published.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent!


Comment thread
coderabbitai[bot] marked this conversation as resolved.
##### Triggered events — error path

`None — error returned only via the reply subject.`

---

### 3.2 history-service

| RPC subject | Method |
Expand Down
92 changes: 92 additions & 0 deletions docs/msgraph-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Microsoft Graph client (`pkg/msgraph`)

A minimal, app-only Microsoft Graph client used by `room-service` to create
Teams **online meetings** for the `teams.meeting` RPC. It exposes only the
surface room-service needs and sits behind a `Client` interface so callers can
be unit-tested against a mock without reaching Azure.

## What it does

- One operation: `CreateOnlineMeeting` — creates (or returns the existing)
Teams online meeting and yields its `joinUrl` + meeting id.
- Authenticates with the **client-credentials (app-only) OAuth2 flow** and
caches the token until it expires.

## Configuration

The client takes a `Config{TenantID, ClientID, ClientSecret}`. `room-service`
populates it from these environment variables (plus the email domain it uses to
derive organizer/attendee addresses):

| Env var | Purpose |
|---|---|
| `TEAMS_TENANT_ID` | Azure AD tenant id (path segment of the token URL) |
| `TEAMS_CLIENT_ID` | App registration (client) id |
| `TEAMS_CLIENT_SECRET` | App registration client secret |
| `TEAMS_EMAIL_DOMAIN` | Domain appended to an `account` to form an email (`account@domain`); defaults to `dev.local` for local/dev |

When the Teams credentials are unset, the deep-link call RPCs still work (they
need only `TEAMS_EMAIL_DOMAIN`); the meetings RPC returns a not-configured
error until the credentials are set.

## Auth flow

1. `POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with
`grant_type=client_credentials`, the client id/secret, and
`scope=https://graph.microsoft.com/.default`.
2. The access token is cached and reused until shortly before expiry.

## Creating a meeting (idempotent)

The client calls Graph's **`createOrGet`** endpoint with a required
`externalId`:

- App-only: `POST {base}/users/{organizerEmail}/onlineMeetings/createOrGet`
- Delegated fallback: `POST {base}/me/onlineMeetings/createOrGet`

`createOrGet` is idempotent at the source of truth: for a given
`(organizer, externalId)` it returns the **existing** meeting if one exists,
otherwise creates one. `room-service` sets `externalId` to a stable per-room key
(`siteID:roomID`), so repeated or concurrent `teams.meeting` calls for the same
room return the same meeting. `externalId` is required — the client rejects an
empty value.

## Production requirement (the live gate)

App-only `onlineMeetings` access is **not** granted by the application
permission alone. Before live use the tenant must have:

1. The **`OnlineMeetings.ReadWrite.All`** application permission, admin-consented
for the app registration; and
2. A **Teams application access policy** (`New-CsApplicationAccessPolicy` +
`Grant-CsApplicationAccessPolicy`) that authorizes the app to create meetings
**on behalf of the organizer user**.

Without the access policy, `createOrGet` returns `403`. This is the one piece
that cannot be exercised by the unit tests and must be validated against the
real tenant.

## Testing without credentials

The client is built to be tested with **no Azure credentials**. The constructor
takes options that point it at local stub servers:

- `WithTokenURL(url)` — override the OAuth token endpoint.
- `WithBaseURL(url)` — override the Graph API base URL.
- `WithHTTPClient(c)` — inject a custom `*http.Client`.

`pkg/msgraph/msgraph_test.go` uses `httptest.NewServer` to stub **both** the
token endpoint and the Graph API, covering: success, idempotent-same-externalId,
required-externalId, token error, Graph error, and missing-joinURL. Because the
client is behind the `Client` interface, the `room-service` meetings handler is
also unit-tested against a generated mock (including a concurrent test that
asserts exactly one meeting + one system message under parallel calls).

Run them with:

```bash
go test ./pkg/msgraph/... ./room-service/...
```

No secrets, no network to Azure — only the live end-to-end smoke (above) needs
the real tenant.
5 changes: 5 additions & 0 deletions pkg/model/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,11 @@ const (
// MessageTypeRoomRestricted is the system-message type emitted when a
// channel's Restricted/ExternalAccess flags change.
MessageTypeRoomRestricted = "room_restricted"
// MessageTypeTeamsMeetStarted is the system-message type emitted when a
// Microsoft Teams online meeting is created for a room. Its SysMsgData
// carries the meeting ID + join URL (TeamsMeetStartedSysData) and is read
// back per-room to make the meetings RPC idempotent.
MessageTypeTeamsMeetStarted = "teams_meet_started"
)

const (
Expand Down
9 changes: 9 additions & 0 deletions pkg/model/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ type RoomRestrictedSysData struct {
OwnerAccount string `json:"ownerAccount,omitempty" bson:"ownerAccount,omitempty"`
}

// TeamsMeetStartedSysData is the JSON payload stored in Message.SysMsgData for
// a teams_meet_started system message — emitted when a Microsoft Teams online
// meeting is created for a room. It is also the read-back source the meetings
// RPC uses for per-room idempotency.
type TeamsMeetStartedSysData struct {
MeetingID string `json:"meetingId" bson:"meetingId"`
JoinURL string `json:"joinUrl" bson:"joinUrl"`
}

type SendMessageRequest struct {
ID string `json:"id"`
Content string `json:"content"`
Expand Down
55 changes: 55 additions & 0 deletions pkg/model/teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package model

// Request/reply contracts for the Microsoft Teams integration RPCs handled by
// room-service. Two endpoints (room call, user call) build a Teams deep link
// with no external I/O; the meetings endpoint creates a Graph onlineMeeting and
// is idempotent per room.

// TeamsRoomCallRequest is the request body for the room-call deep-link RPC.
// The room is carried on the NATS subject, so the body may be empty; the field
// is accepted for clients that prefer to pass it explicitly.
type TeamsRoomCallRequest struct {
// RoomID is optional — the authoritative room is the subject's {roomID}.
RoomID string `json:"roomId,omitempty"`
}

// TeamsUserCallRequest is the request body for the 1:1 user-call deep-link RPC.
type TeamsUserCallRequest struct {
// AccountName is the target user's account; its email is derived as
// account@TEAMS_EMAIL_DOMAIN.
AccountName string `json:"accountName"`
}

// TeamsCallReply is the reply for both deep-link RPCs (calls/room, calls/user).
type TeamsCallReply struct {
JoinURL string `json:"joinUrl"`
}

// TeamsMeetingRequest is the request body for the meetings RPC. The room is
// carried on the NATS subject; the body may be empty.
type TeamsMeetingRequest struct {
// RoomID is optional — the authoritative room is the subject's {roomID}.
RoomID string `json:"roomId,omitempty"`
}

// TeamsMeetingReply is the reply for the meetings RPC: the Graph onlineMeeting
// ID and its join URL.
type TeamsMeetingReply struct {
ID string `json:"id"`
JoinURL string `json:"joinUrl"`
}

// TeamsMeetingRecord is the first-class persisted record of a room's Teams
// meeting in the teams_meetings collection. A UNIQUE index on (roomId, siteId)
// makes the meetings RPC retry-safe: a concurrent second create hits a
// duplicate-key error, and the loser reads back the winner's record instead of
// creating a duplicate system message. This is the same unique-index +
// IsDuplicateKeyError idempotency convention room-service already uses for
// room_members and subscriptions.
type TeamsMeetingRecord struct {
RoomID string `bson:"roomId"`
SiteID string `bson:"siteId"`
MeetingID string `bson:"meetingId"`
JoinURL string `bson:"joinUrl"`
CreatedAt int64 `bson:"createdAt"`
}
Loading
Loading