# Zendesk

> Give each of your customers a memory of their support history. Every ticket goes into its requester's own memory with each public reply, internal notes stay out, and after the first run only the tickets that changed are read.

Give each of your customers a memory of their support history, so your assistant or your agents start from
what happened last time. This sync reads your Zendesk tickets and adds each one to its requester's own
memory, with every public reply, who wrote it and when. Internal notes stay out, so nothing an agent wrote
for colleagues reaches the customer through your assistant. It reads through Zendesk's incremental export:
the first run reads every ticket since a time you choose, and each run after it only the tickets that
changed, including the ones deleted.

## Install

```bash
pip install httpx geniffy
```

```bash
uv add httpx geniffy
```

Set `GENIFFY_API_KEY` from **API keys** in the Geniffy app. In Zendesk's Admin Center, under **Apps and
integrations**, make an API token, and use it with the email address of an admin.

## The sync

```python
import time

import httpx
from geniffy import Geniffy, NotFoundError

geniffy = Geniffy()                               # reads GENIFFY_API_KEY
LABELS = {"channel": "zendesk"}


def zendesk(subdomain: str, email: str, api_token: str) -> httpx.Client:
    return httpx.Client(base_url=f"https://{subdomain}.zendesk.com/api/v2", timeout=60,
                        auth=(f"{email}/token", api_token))


def space_for(ticket: dict) -> str:
    """The customer who asked, by their Zendesk user id. If you know them by your own id, use that."""
    return f"customer_{ticket['requester_id']}"


def thread(api: httpx.Client, ticket: dict) -> str:
    """A ticket as one note: its subject and state, then each public reply with who wrote it and when."""
    parts = [f"Ticket #{ticket['id']}: {ticket['subject']} ({ticket['status']})."]
    url, params = f"/tickets/{ticket['id']}/comments", {"include": "users", "page[size]": 100}
    while url:
        out = api.get(url, params=params).raise_for_status().json()
        names = {user["id"]: user["name"] for user in out.get("users", [])}
        for comment in out["comments"]:
            if comment["public"] and comment["plain_body"].strip():     # internal notes stay out
                who = names.get(comment["author_id"], "someone")
                parts.append(f"From {who}, {comment['created_at'][:10]}:\n{comment['plain_body']}")
        url, params = (out["links"]["next"] if out["meta"]["has_more"] else None), None
    return "\n\n".join(parts)


def sync(subdomain: str, email: str, api_token: str, cursor: str | None = None, start_time: int = 0) -> str:
    """Bring the tickets that changed into each customer's memory. Returns the cursor to pass next time: pass
    None the first time, and every ticket changed since start_time (a Unix time) is read."""
    with zendesk(subdomain, email, api_token) as api:
        params = {"cursor": cursor} if cursor else {"start_time": start_time}
        while True:
            got = api.get("/incremental/tickets/cursor", params=params)
            if got.status_code == 429:            # ten calls a minute to this one: wait as Zendesk asks
                time.sleep(int(got.headers.get("Retry-After", "60")))
                continue
            out = got.raise_for_status().json()
            for ticket in out["tickets"]:
                mem, ref = geniffy.space(space_for(ticket)), f"zendesk:{ticket['id']}"
                if ticket["status"] == "deleted":
                    try:
                        mem.sources.delete(external_id=ref)
                    except NotFoundError:
                        pass
                else:
                    mem.memories.add(thread(api, ticket), title=f"#{ticket['id']} {ticket['subject']}",
                                     said_at=ticket["updated_at"], external_id=ref, labels=LABELS)
            if out["end_of_stream"]:
                return out["after_cursor"]
            params = {"cursor": out["after_cursor"]}
```

Run it on a schedule, and keep the cursor each run returns for the next:
`cursor = sync("acme", "admin@acme.com", token, cursor)`. On the first run, `start_time` says how far back to
read, as a Unix time at least a minute in the past.

## Recall from it

When a customer writes in, put what is known about them in front of your model:

```python
known = geniffy.space(f"customer_{requester_id}").context(message)
```

Each ticket is cited by its number and subject, so an answer can point to it.

## How it behaves

- **Each ticket is one source,** in its requester's memory, under its number and dated by its last update. A
  new reply sends the ticket again, and only what it adds is learned. See [Your own ids](https://docs.geniffy.com/add-memories/your-own-ids).
- **Only public replies.** Internal notes are left out, so your assistant never repeats to a customer what
  agents wrote for each other.
- **Only what changed is read.** After the first run, the cursor names the tickets that changed. A ticket
  deleted in Zendesk is deleted from memory with what it taught.
- **One memory for each customer,** so what one customer said never reaches another. If your app knows them by
  its own id, change `space_for` to use it. To forget a customer, erase their space:
  `geniffy.forget_space(f"customer_{requester_id}")`.

Source: https://docs.geniffy.com/integrations/zendesk
