# Direct messages

Direct messages are the private conversations between members. These endpoints move the conversations of the old
platform in, with their original dates and authors, so members keep their history. Read [Start here](start.md) first:
its rules apply to everything on this page. Direct messages work like [Comments](comments.md) in some ways, and this
page says where they differ.

**The API only puts conversations in. It never reads them.** Nothing on this page lets you, or the admin, see anyone's
conversations through Zen Social.

Import the posts, events and comments first, and the direct messages after them (the rota, when it comes, is last).
A conversation can only be moved when
every person in it has already joined Zen Social, so a move runs in this order: members join, you match them with
`GET /members`, and then the conversations come in.

## What the API has, and what it does not

- **Has:** moving a conversation in, in one request or in batches of messages, with its original authors and dates, and
  photos. And undoing the move of a conversation, while nobody has touched it.
- **Not in the API:** reading, listing, searching or exporting conversations or messages. There is no `GET`. An answer
  holds a status and four counts, and never the text, the photo addresses, a Zen Social id or anything else about a
  conversation. It does not even say whether two people already talked in Zen Social.
- **Not moved:** people who are not members of the community (a conversation with someone who has not joined is
  not moved at all), reactions, read receipts, edit histories, voice messages and files other than photos. A
  conversation has no name.
- **Not sending:** an import is not sending. Nobody is told, and nothing you move in looks new (see "Nobody is told").
- **Not a check on what is true:** the API can't tell an export from an invented conversation. Any key with the
  permission can put in a conversation between any members, with any text and any dates, and it looks like the real
  thing. The safeguards are the rules on this page and in [Start here](start.md#rules-for-assistants), and the line
  Zen Social keeps of every request: the key, the admin who made it, the community, your `external_id` for the
  conversation and the counts (see "What Zen Social keeps of a request").

## Before the first import: the members must be told

The community tells its members **before** the move that their old private conversations are moving to Zen Social.
That is a condition for using this part of the API, and Zen Social does not ask each member again. **Before the first
real import, ask the admin to confirm that the members have been told.** If the admin has not told them yet, stop and
tell the admin so. Do not start with "just a test" on real people's conversations either: see "Rules for assistants" in
[Start here](start.md#rules-for-assistants).

## Match every person with the admin first

A wrong match puts a private conversation in front of the wrong person, and an undo cannot take back that it was seen.
So **before any direct-message import, show the admin the full mapping**: each person in the old conversations, and the
Zen Social member (name and `id`) you will use for them. Wait for an explicit yes. Never guess between similar names.
A person you cannot match with certainty means that conversation is **not** imported: leave it out and tell the admin.

Move conversations only as they are in the old platform's export, between the people who were in them: never compose,
reword, add or leave out a message, and never make a conversation that did not exist.

## The key needs a permission

A key can move direct messages only if it was made with the permission **May import direct messages**. An owner or
admin ticks it when making the key, on the tab **Import & AI access**. It cannot be added to a key later: the admin
makes a new key. The key list shows which keys have it. See [Keys and access](keys.md#the-direct-message-permission).

A key without the permission answers `403 {"error":"forbidden"}` on both endpoints below, and nothing is written. The
admin who made the key must be an owner or admin of the community, as for every key.

## Import one: `POST /conversations`

One request moves one conversation, or the next batch of its messages. The conversation is named by your own
`external_id` for it. The people are the members' ids from [Members](members.md).

```sh
curl "$API/conversations" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-thread-3001", "participant_ids": ["EveABqLyto9L", "Qm3TzK8pWn2D"], "messages": [{"external_id": "fb-msg-1", "author_id": "EveABqLyto9L", "created_at": "2023-04-15T12:30:00+02:00", "body": "Are you coming to the seed swap on Saturday?"}, {"external_id": "fb-msg-2", "author_id": "Qm3TzK8pWn2D", "created_at": "2023-04-15T12:41:00+02:00", "body": "Yes! I will bring the tomato seedlings."}]}'
```

| Field | Rules |
|---|---|
| `external_id` | Required: your id for the conversation on the old platform, as text or a number, at most 200 characters, and not starting with `fb:`. |
| `participant_ids` | Required: the members' `id`s from [Members](members.md). At least 2, all different. |
| `messages` | Required: 1 to 100 messages (below). |

Each message in `messages`:

| Field | Rules |
|---|---|
| `external_id` | Required: your id for the message, as text or a number, at most 200 characters, **without a `/`**, and different for every message in the request. |
| `author_id` | Required: a member's `id`. The author need not be one of the `participant_ids`: see "Authors who left". |
| `body` | The text, as plain text, at most 4000 characters. May be empty only when the message has photos. |
| `created_at` | Required: when it was written, in ISO 8601 **with an offset** (`2023-04-15T12:30:00+02:00` or `…Z`), from 1990 and not in the future. A date alone, or a time without an offset, is refused. |
| `image_urls` | Optional: a list of picture addresses for Zen Social to fetch. |
| `images` | Optional: picture files in a multipart form (below). |

Fields the API does not know are ignored.

### Everyone must be a member

Every person in `participant_ids` and every `author_id` must be a member of the community. Someone who is in one of
its circles is a member of the community too. If anyone is not, **the whole conversation is refused**: nothing is
moved, and the answer names the ids you sent that did not match:

```json
{"error": "participants_not_found", "details": {"participant_ids": ["fb-user-77"], "author_ids": ["fb-user-81"]}}
```

A list with nothing unmatched is left out of `details`. The answer is the same for an id that does not exist and for a
member of another community, and names only ids you sent. Do not guess: tell the admin who is missing. When those
people have joined, send the conversation again.

### Authors who left

A person who wrote in the old thread but is no longer in it may be the `author_id` of their messages without being in
`participant_ids`. Their messages keep their name, but they are **not added** to the conversation, so they cannot see
it. They must still be members.

### What the conversation becomes

- Two people who have no direct conversation in Zen Social yet get their direct conversation.
- Two people who already talk in Zen Social get the old thread as a **separate** conversation. Their live conversation
  is not touched, and the old messages are not mixed into it. In the app it is listed under the other person's name,
  with the date of its own last message.
- Three or more people get a group conversation. **A group may hold more than 8 people**, as moved conversations are
  allowed to. Nobody can be added to such a group in the app afterwards.
- Conversations have no names. **Every moved conversation opens with a small line** saying "Moved from the earlier
  system · 27 Oct 2026" ("Flyttet fra det tidligere system · 27. okt 2026" in Danish),
  dated the day it was moved, by the
  UTC calendar (near midnight in Denmark that can be the day before).

### Blocks

A block between people in the conversation **does not stop the move**: the conversation comes in as if the block came
after it. Zen Social's usual block rules then apply, as for a conversation people had in the app before one of them
blocked the other:

- The person who blocked does not see a conversation of just the two of them in their inbox.
- Nobody can write to someone who blocked them, so a conversation of just the two of them is read-only for both.
- In a larger group, the person who blocked sees the other's messages folded away.

The answer is the same as without a block, so it never tells you, or the admin, who has blocked whom.

### Photos

`image_urls` and `images` work as for posts (see [Pictures](posts.md#pictures-images-and-image_urls)), with these
limits: at most **10 photos on a message and 20 in a request**, counted before any is fetched. A photo **file**
(multipart) takes at most 10 MB. A photo **address** is fetched while you wait, at most 5 MB each.

Photos are JPEG, PNG or AVIF only, judged from the file's contents, not its name. Each is saved again, turned upright
and without its metadata: the camera's details and the place it was taken are not kept. An AVIF is stored as a JPEG, or
as a PNG when it is transparent.

- **Left out and counted in `skipped_files`**, for files and for fetched addresses alike: a photo of another type (a GIF,
  WebP or HEIC, say), one whose start cannot be read as a picture, one too large in pixels, or a file over 10 MB. The message goes in without it.
- A message left with no text and no photo is **skipped** and counted in `skipped_messages`. A message sent with an
  empty `body` and no photos at all is not that: it is refused (`422 invalid`).
- **Refused with `422 invalid`, and nothing is moved:** an address that cannot be fetched, is over 5 MB, or does not
  give an image at all (a web page, say) (under `messages[2].image_urls` for the third message), and a photo with a good start but a damaged
  picture (under `messages[2].images`). Try again later, or leave the photo out and tell the admin.
- On a re-run (below) no photo is downloaded again for a message that is already moved.

Photos as files go in a multipart form. A list is `participant_ids[]`, and each message's fields are
`messages[][external_id]`, `messages[][author_id]`, `messages[][body]`, `messages[][created_at]` and
`messages[][images][]`. **Start every message with its `external_id`:** that is how the form is told where one message
ends and the next begins.

```sh
curl "$API/conversations" \
  -H "Authorization: Bearer $KEY" \
  -F "external_id=fb-thread-3002" -F "participant_ids[]=EveABqLyto9L" -F "participant_ids[]=Qm3TzK8pWn2D" \
  -F "messages[][external_id]=fb-msg-1" -F "messages[][author_id]=EveABqLyto9L" \
  -F "messages[][created_at]=2023-05-02T18:30:00+02:00" -F "messages[][body]=Photos from Saturday" \
  -F "messages[][images][]=@saturday-1.jpg" -F "messages[][images][]=@saturday-2.jpg"
```

The photos are private to the people in the conversation, as photos sent in the app are.

## The answers

A request is either moved whole or not at all. One that is refused writes nothing.

`201 Created`, when something was moved, and `200 OK`, when there was nothing new, answer with the same four counts:

```json
{"status": "created", "imported": 2, "already_imported": 0, "skipped_messages": 0, "skipped_files": 0}
```

| Field | Meaning |
|---|---|
| `status` | `created`, or `already_imported` when the request held nothing new. |
| `imported` | How many messages this request moved in. |
| `already_imported` | How many messages of the request were moved in by an earlier request. |
| `skipped_messages` | How many messages had no text and no photo Zen Social could take. |
| `skipped_files` | How many photo files were left out (above). |

The refusals:

| Status | `error` | Meaning |
|---|---|---|
| `403` | `forbidden` | The key does not have the permission. |
| `422` | `participants_not_found` | Someone is not a member of the community (above). |
| `422` | `not_allowed` | Rare: someone in the conversation, or one of its authors, was banned from the community while the request ran (a ban ends the membership, so otherwise the answer is `participants_not_found`). Nothing was moved. Tell the admin. A block is never a refusal (see "Blocks"). |
| `422` | `older_than_imported` | A later batch that starts before the newest message already moved (below). |
| `422` | `invalid` | A value is not allowed. `details` names the field, and for a message its place in the list, counting from 0, for example `messages[3].created_at`. It never repeats the text. A first import in which not one message has text or a photo Zen Social can take is `invalid` too, under `details.messages`. |
| `409` | `cannot_continue` | The conversation was moved, but can take no more messages (below). |
| `409` | `conflict` | The same conversation was sent twice at the same moment. Send it again, once. |
| `429` | | Too many messages or requests (below). |

## Batches, and re-runs

A long conversation can come in several requests of up to 100 messages. Send the **same `external_id` and the same
`participant_ids`** every time, and send the batches **in date order**: the oldest messages first.

- Inside one request the order does not matter: the messages go in sorted by `created_at`, and messages with the same
  time in the order you sent them.
- A batch whose first message is **older than the newest message already moved** is refused with
  `422 older_than_imported`. Nothing is moved.
- A batch can continue the conversation only while nobody has done anything in it. Once anyone has written in it,
  reacted to, edited, deleted or reported a message, or left, a batch with new messages is refused with
  `409 cannot_continue`. The same answer comes for different `participant_ids` than the first time, and for a
  conversation that has been removed. Skip the rest of that conversation and tell the admin.
- While a batch that continues a conversation is being written, photos included, the members' own messages to that
  conversation wait until it has finished. A batch with many photos can take a while, so send continuations with
  photos in small batches.
- **A request with nothing new answers `200 already_imported`**, whatever has happened since, even when the
  conversation has been touched. When every message in it was moved before, that holds even if someone in it has left
  the community since. That makes it safe to run a whole script again after a crash: what is moved is not
  moved twice, and no old conversation meets a refusal. The messages are recognised by their `external_id`.

## Undo one: `DELETE /conversations/:external_id`

If a conversation was moved in wrongly, the admin can have it taken out again, without anyone reading it. The address
holds your `external_id` for the conversation, percent-encoded (a `/` in it as `%2F`):

```sh
curl -X DELETE "$API/conversations/fb-thread-3001" -H "Authorization: Bearer $KEY"
```

Answers `204 No Content`. The conversation and the messages you moved in are removed from the people's inboxes at once,
photos included. Then the same conversation can be moved in again.

- `404 not_found` when no conversation was moved in with that `external_id`, and for a second undo of the same one.
- `409 cannot_undo` when anyone has touched the conversation since (the same list as for continuing: written, reacted,
  edited, deleted, reported, left). It stays as it is. Tell the admin: what people have done in a conversation is
  theirs.
- `422 invalid` when the id starts with `fb:` or is longer than 200 characters.
- Any key with the permission in the same community may undo, not only the key that moved it in.

Undo is for one conversation you moved yourself. **Confirm with the admin before you undo**: say which conversation it
is by your own id, and wait for their yes. There is nothing that undoes many at once, and there is no other way to
delete a conversation through the API.

## Limits

- At most **100 messages** and **20 photos** in a request.
- At most **1,000 messages a minute per key**. Every message in a request that passes the checks on its shape and
  the permission counts, whether or not the import is then accepted, and whether or not the message was already
  moved. A request refused earlier (`403`, or `422 invalid` for its shape) counts nothing. Above that the answer is
  `429` with `Retry-After`, as for too many requests (see
  [Keys and access](keys.md#how-fast-you-may-go-429)). Wait, then send the same request again.
- The usual limits on requests also apply: 300 a minute per key, and 600 from one network address.

Send one request at a time. A request with photo addresses is slow, as it fetches them.

## What Zen Social keeps of a request

Every request leaves one line in Zen Social's log: the key, the admin who made it, the community, that the key has the
permission, your `external_id` for the conversation, how many people it names and how many messages it holds. Never
the text, the photos or their names. Do not put your own secrets in an `external_id`.

## Nobody is told

Moving a conversation in, continuing it and undoing it tell no member anything: no bell, no push message, no email, no
live update on a screen that is open. The conversation does not show as unread to anyone. It is read by everyone in it,
and "Seen by" shows them all. It sits in the inbox by the date of its last message, so a conversation from 2023 is far
down the list. People see it when they open their inbox.
