# Zengin mesajlar

Sayfa: https://docs.sonecentral.com/zengin-mesajlar
Markdown: https://docs.sonecentral.com/zengin-mesajlar.md

> Ürün ve hizmet kartları, düğmeler, hızlı yanıtlar ve dosyalar SoneMessage nesnesi olarak gelir; her birinin düz metin karşılığı da vardır.

Asistan WhatsApp'ta ürün kartı, düğme ya da belge gönderebiliyorsa API'de de gönderir. API onları belirli bir uygulamanın biçiminde değil, sizin istediğiniz gibi çizebileceğiniz yapılandırılmış veri olarak verir. Bu verinin adı `SoneMessage`. Ekibiniz Mesajlar ekranından bir ürün, hizmet ya da belge paylaştığında da aynı yapı gelir, `role: "staff"` ile: [Ekibin paylaştıkları](https://docs.sonecentral.com/insan-devri.md#ekibin-paylastiklari).

## Nerede gelir

| Biçim | Yer |
| --- | --- |
| Chat Completions | Akışsız yanıtta `choices[0].message.sone.messages`. Akışta her mesaj, kendi son parçasının üst düzeyindeki `sone.message` alanında gelir. |
| Responses | Her `message` çıktı öğesinin `sone` alanı. |
| Vercel AI SDK | `data-sone-catalog`, `data-sone-actions`, `data-sone-file` ve `data-sone-staff` parçaları. Asistanın düz metin mesajları `text` parçası olarak gelir. |
| Sohbetin mesajları | `GET /conversations/{id}/messages` yanıtının `data` dizisi. |

Vercel AI SDK parçalarının `data` alanı `SoneMessage`'ın tamamını değil, çizmek için gerekeni taşır: `text`, `role` ve mesajın türüne göre `catalog`, `actions` ya da `file`. `data-sone-staff` yalnız ekibinizin düz metin mesajları içindir; ekip kart, düğme ya da dosya gönderirse ilgili parça `data.role: "staff"` ile gelir. Ayrıntılar: [Vercel AI SDK](https://docs.sonecentral.com/vercel-ai-sdk.md#tipli-mesajlar).

## Düz metin her zaman vardır

Her `SoneMessage`'ın `text` alanı doludur. Ürün kartı içeren bir mesajda bu, ürünlerin adını, fiyatını ve adresini sayan düz bir metindir. Zengin mesajları çizmeyen bir istemci yalnız `text`'i gösterebilir. Çok uzun bir katalog paylaşımında `text` kısalır, kayıtların tamamı yine `catalog.items`'tadır: [Katalog](#katalog). OpenAI biçimlerinde aynı metin `content` alanında da vardır.

## `SoneMessage`

```ts title="sone-message.ts"
export type SoneMessage = {
  id: string; // "msg_…"
  role: 'assistant' | 'staff' | 'user'; // staff: ekibinizden biri yazdı
  type: 'text' | 'catalog' | 'actions' | 'file';
  text: string; // düz metin karşılığı
  created_at: number; // Unix saniyesi
  catalog?: SoneCatalog;
  actions?: SoneActions;
  file?: SoneFile;
};

export type SoneCatalog = {
  // İşletmenin seçtiği sunum: "card", "card_button", "image_text", "link_button", "text", "carousel" ya da "list"
  format: string;
  items: SoneCatalogItem[];
};

export type SoneCatalogItem = {
  id: string; // kaydın Sonecentral'daki kimliği
  kind: 'inventory' | 'offering'; // ürün ya da hizmet
  title: string;
  description?: string;
  price?: string; // biçimlendirilmiş fiyat: "1.290,00 TL"
  detail?: string; // stok, süre gibi kısa bilgi
  attributes?: string; // renk, beden gibi özellikler, düz metin
  image_url?: string;
  url?: string; // ürünün ya da hizmetin sayfası
  button_title?: string; // url düğmesinin yazısı; url varsa gelir
};

export type SoneActions = {
  format: 'buttons' | 'quick_replies' | 'card' | 'link_button';
  title?: string;
  text?: string;
  image_url?: string;
  buttons?: { title: string; url: string }[];
  choices?: string[];
};

export type SoneFile = {
  id: string; // "file_…"
  name: string;
  mime_type: string;
  size: number; // bayt
  url: string; // imzalı indirme bağlantısı
};
```

`role: "user"` yalnız sohbetin mesaj listesinde görünür ve ziyaretçinin kendi satırlarıdır; bu satırların `type` değeri her zaman `text` olur. Bir turun yanıtındaki mesajlar her zaman işletme tarafındandır: `assistant` ya da `staff`.

Boş alanlar gönderilmez: `description`, `price`, `image_url` gibi bir alan yoksa nesnede de yoktur.

### Katalog

Bir paylaşım 1 ile 10 kayıt taşır, asistanın seçtiği sırayla.

```json
{
  "id": "msg_c4t2l9g7x1p3h8q",
  "role": "assistant",
  "type": "catalog",
  "text": "Keten gömlek, taş rengi\n1.290,00 TL\nM ve L stokta\nhttps://www.ornek.com/urun/keten-gomlek-tas\n\nKeten gömlek, lacivert\n1.290,00 TL\nYalnız M kaldı\nhttps://www.ornek.com/urun/keten-gomlek-lacivert",
  "created_at": 1760866872,
  "catalog": {
    "format": "carousel",
    "items": [
      {
        "id": "r8k2m9p4q7w1x3z",
        "kind": "inventory",
        "title": "Keten gömlek, taş rengi",
        "price": "1.290,00 TL",
        "detail": "M ve L stokta",
        "image_url": "https://cdn.ornek.com/urun/keten-tas.jpg",
        "url": "https://www.ornek.com/urun/keten-gomlek-tas",
        "button_title": "Ürünü incele"
      },
      {
        "id": "r4q7w1x8k2m9a5z",
        "kind": "inventory",
        "title": "Keten gömlek, lacivert",
        "price": "1.290,00 TL",
        "detail": "Yalnız M kaldı",
        "url": "https://www.ornek.com/urun/keten-gomlek-lacivert",
        "button_title": "Ürünü incele"
      }
    ]
  }
}
```

`format` işletmenin bu kayıtlar için seçtiği sunumdur. Tek kayıtta kaydın kendi sunumu gelir: `card`, `card_button`, `image_text`, `link_button` ya da `text`. Birden çok kayıt `carousel` olur; hepsi liste olarak seçilmişse `list`. Size yol gösterir ama bağlamaz: bir listeyi kart olarak çizmek de doğrudur. Hizmetler `kind: "offering"` ile aynı alanlarla gelir; `detail` genellikle süreyi taşır.

`text`, her kayıt için ad, açıklama, fiyat, kısa bilgi, özellikler ve adresi alt alta yazar; kayıtlar arasında boş bir satır olur.

`text` en fazla 4.000 karakterdir. Örneğin uzun açıklamalı on ürün buna sığmazsa yer kayıtlar arasında paylaştırılır. Payına sığan kayıt tam yazılır. Sığmayan kayıtta önce ad, fiyat ve kısa bilgi gelir, adres sığıyorsa en sona yazılır; özellikler ve açıklamanın tam cümleleri kalan yere eklenir. Bir alan ya tam yazılır ya hiç yazılmaz, ortadan kesilmez. `catalog.items` her kaydı her zaman eksiksiz taşır; kartları oradan çizin, `text`'i yedek olarak kullanın.

### Düğmeler ve hızlı yanıtlar

```json
{
  "id": "msg_a7c1t9n2k4q8z3w",
  "role": "assistant",
  "type": "actions",
  "text": "Randevunuz için hangi gün uygun?\nPerşembe\nCuma\nCumartesi",
  "created_at": 1760866923,
  "actions": {
    "format": "quick_replies",
    "text": "Randevunuz için hangi gün uygun?",
    "choices": ["Perşembe", "Cuma", "Cumartesi"]
  }
}
```

| `format` | Ne taşır |
| --- | --- |
| `buttons` | `text` ve 1 ile 3 arası seçenek, `choices` içinde. |
| `quick_replies` | `text` ve 1 ile 13 arası seçenek, `choices` içinde. |
| `link_button` | `text` ve bir bağlantı düğmesi, `buttons` içinde. |
| `card` | `title`, kısa bir `text`, isteğe bağlı `image_url` ve en fazla 3 bağlantı düğmesi, `buttons` içinde. |

Bir seçenek ve bir düğme başlığı en fazla 20 karakterdir. `text` alanı başlığı, metni, her bağlantı düğmesini `Başlık: adres` biçiminde ve seçenekleri alt alta yazar.

- Her düğme bir `url` taşır ve o adresi yeni sekmede açar.
- Bir seçeneğe tıklanınca başlığını ziyaretçinin yeni mesajı olarak gönderin. Asistan onu ziyaretçinin yazdığı bir mesaj gibi okur.
- Seçenekler yalnız ziyaretçinin bir sonraki mesajına kadar anlamlıdır. Asistan seçeneklerin ardından bir metin mesajı da yazabilir; seçenekleri ziyaretçi yeniden yazana kadar gösterin, sonra gizleyin.

### Dosya

```json
{
  "id": "msg_f1l3d0c5x9w2m6r",
  "role": "assistant",
  "type": "file",
  "text": "Fiyat listesi",
  "created_at": 1760867010,
  "file": {
    "id": "file_p9x2k4m7q1w8e3t",
    "name": "fiyat-listesi-2026.pdf",
    "mime_type": "application/pdf",
    "size": 188416,
    "url": "https://pb.sonecentral.com/api/v1/files/file_p9x2k4m7q1w8e3t?exp=1761471810&sig=…"
  }
}
```

`text` dosyanın açıklamasıdır; açıklama yoksa dosyanın adıdır. Bağlantıyı bir `<a download>` ile verin. Bir dosya en fazla 32 MB olabilir.

Aynı dosya aynı sohbette yeniden paylaşılırsa `file.id` aynı kalır. Başka bir sohbette dosya kendi kimliğiyle ve kendi bağlantısıyla gelir. Kaç sohbette paylaşılırsa paylaşılsın dosya çalışma alanınızın depolamasında bir kez yer tutar.

## Dosyayı indirmek

`file.url`, `GET /files/{id}` uç noktasının imzalı adresidir:

```http
GET https://pb.sonecentral.com/api/v1/files/{id}?exp={son_geçerlilik}&sig={imza}
```

- Anahtar gerekmez, adresteki imza yeter. Adresi olduğu gibi kullanın.
- Bağlantı **yedi gün** geçerlidir. Sohbetin mesajlarını her okuduğunuzda yedi gün geçerli yeni bir bağlantı gelir.
- Yanıt her zaman indirme olarak gelir (`Content-Disposition: attachment`) ve dosyanın adını taşır; Türkçe harfli bir ad `filename*` ile gelir. Tarayıcı dosyayı sayfanızın içinde açmaz.
- Süresi dolmuş ya da bozuk bir bağlantı `404 file_not_found` alır. Dosya o an okunamıyorsa `503 file_unavailable` döner.
- Aynı dosya kimliği saatte en fazla 10 kez indirilir; fazlası `429 file_download_limit` alır. Çalışma alanının günlük indirme sınırı (varsayılan 2 GiB, UTC gününe göre) dolunca `429 file_egress_limit` gelir. İkisi de `Retry-After` taşır ve isteğin hangi adresten geldiğine bakmaz.
- Aynı adresten dakikada 120'den fazla indirme isteği de `429` alır; bu yanıtın gövdesi OpenAI hata biçiminde değildir.

## Çizmek

Aşağıdaki işlev bir `SoneMessage`'ı DOM'a çevirir. Bütün metinleri `textContent` ile yazar, `innerHTML` kullanmaz; böylece hiçbir alan sayfanıza kod sokamaz.

```js title="render-sone-message.js"
function el(tag, props = {}, ...children) {
  const node = document.createElement(tag);
  for (const [key, value] of Object.entries(props)) {
    if (value == null) continue;
    if (key === 'class') node.className = value;
    else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
    else node.setAttribute(key, value);
  }
  for (const child of children.flat()) {
    if (child == null || child === false) continue;
    node.append(typeof child === 'string' ? document.createTextNode(child) : child);
  }
  return node;
}

export function renderSoneMessage(message, { onChoose }) {
  if (message.type === 'catalog' && message.catalog) {
    return el(
      'div',
      { class: 'sone-catalog' },
      message.catalog.items.map((item) =>
        el(
          'article',
          { class: 'sone-item' },
          item.image_url ? el('img', { src: item.image_url, alt: '', loading: 'lazy' }) : null,
          el('strong', {}, item.title),
          item.detail ? el('span', {}, item.detail) : null,
          item.price ? el('span', { class: 'sone-price' }, item.price) : null,
          item.url ? el('a', { href: item.url, target: '_blank', rel: 'noopener' }, item.button_title || 'İncele') : null,
        ),
      ),
    );
  }

  if (message.type === 'actions' && message.actions) {
    const { title, text, buttons = [], choices = [] } = message.actions;
    return el(
      'div',
      { class: 'sone-actions' },
      title ? el('strong', {}, title) : null,
      text ? el('p', {}, text) : null,
      buttons.map((button) => el('a', { href: button.url, target: '_blank', rel: 'noopener' }, button.title)),
      choices.map((choice) => el('button', { type: 'button', onclick: () => onChoose(choice) }, choice)),
    );
  }

  if (message.type === 'file' && message.file?.url) {
    return el('a', { class: 'sone-file', href: message.file.url, download: message.file.name }, message.file.name);
  }

  return el('p', { class: message.role === 'staff' ? 'sone-text sone-staff' : 'sone-text' }, message.text);
}
```

Tam bir pencere içinde kullanımı: [Site içine sohbet penceresi](https://docs.sonecentral.com/sohbet-penceresi.md).
