Для разработчиков

Первый бот

Здесь собран путь от создания бота до первого разговора: ваш сервис пишет в чат, сотрудник отвечает, а бот получает ответ и пишет в тред. Примеры кода написаны на TypeScript и Python, подробности каждого шага — по ссылкам.

Как это работает:

  1. Сервис отправляет сообщение методом POSTНовое сообщение с токеном бота
  2. Сотрудник отвечает, и Пачка присылает событие на адрес вашего обработчика: это исходящий вебхук
  3. Обработчик проверяет подпись запроса и отвечает в тред тем же методом

Для приёма событий нужен адрес, доступный из интернета. Пока обработчик работает на вашей машине, события получают через туннель или потоком, подробнее в разделе Разработка на своей машине.

Подготовьте бота

Создайте бота

В веб-версии или десктопном приложении Пачки откройте Интеграции > Боты и нажмите Создать бота. Задайте название, а ник можно оставить пустым: он соберётся из названия. Кнопка есть у тех, кому разрешено создавать интеграции, подробнее в разделе Кто может создавать токены. Другие способы и настройки — в разделе Создание бота.

Выпустите токен

На вкладке API в карточке бота нажмите Создать токен и выберите права из строки «Бот отвечает на сообщения и команды» в разделе Наборы прав под типовые задачи. Коду на этой странице из набора нужны права messages:create, threads:create и profile:read. Значение токена показывается один раз: скопируйте его до закрытия окна. Подробнее — в разделе Токены бота.

Добавьте бота в беседу

Создайте беседу для проверки и добавьте в неё бота в окне Добавление участников на вкладке Боты. События о сообщениях бот получает из чатов, где состоит, подробнее в разделе Доступы к чатам и сообщениям.

Узнайте идентификатор беседы

Откройте беседу в веб-приложении: идентификатор — число в адресе после /chats/, например 12345 в https://app.pachca.com/chats/12345. Другие способы — в разделе Где взять идентификаторы.

Отправьте сообщение

Метод POSTНовое сообщение принимает идентификатор чата в entity_id и текст в content. Подставьте идентификатор своей беседы вместо 12345, а токен бота передайте в переменной окружения PACHCA_BOT_TOKEN.

cURL
curl -X POST https://api.pachca.com/api/shared/v1/messages \  -H "Authorization: Bearer $PACHCA_BOT_TOKEN" \  -H "Content-Type: application/json" \  -d '{    "message": {      "entity_type": "discussion",      "entity_id": 12345,      "content": "Привет! Ответьте в треде под этим сообщением, и я отвечу"    }  }'
send.ts
const API = "https://api.pachca.com/api/shared/v1" async function sendMessage(chatId: number, content: string) {  const response = await fetch(`${API}/messages`, {    method: "POST",    headers: {      Authorization: `Bearer ${process.env.PACHCA_BOT_TOKEN}`,      "Content-Type": "application/json",    },    body: JSON.stringify({ message: { entity_type: "discussion", entity_id: chatId, content } }),  })  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`)  return (await response.json()).data // объект сообщения} sendMessage(12345, "Привет! Ответьте в треде под этим сообщением, и я отвечу").then((message) => {  console.log("id сообщения:", message.id)})
send.py
import os import httpx response = httpx.post(    "https://api.pachca.com/api/shared/v1/messages",    headers={"Authorization": f"Bearer {os.environ['PACHCA_BOT_TOKEN']}"},    json={        "message": {            "entity_type": "discussion",            "entity_id": 12345,            "content": "Привет! Ответьте в треде под этим сообщением, и я отвечу",        }    },)response.raise_for_status()message = response.json()["data"]  # объект сообщенияprint("id сообщения:", message["id"])

В ответе придёт объект сообщения в поле data. Сохраните его id: по нему вы узнаете, что сотрудник ответил именно на это сообщение, подробнее в разделе Ответы на сообщения сервиса. Если запрос вернул ошибку, код и причина разобраны в разделах Ошибки авторизации и Ошибки.

Включите исходящий вебхук

Укажите адрес обработчика

На вкладке Исходящий вебхук в карточке бота нажмите Включить, в поле Webhook URL укажите адрес обработчика, доступный из интернета, например https://bot.example.com/webhook, и подтвердите кнопкой Включить. Для разработки подойдёт адрес туннеля, подробнее в разделе Разработка на своей машине.

Выберите события

Отметьте Новые сообщения и выберите режим Любые сообщения: в режиме Начинающиеся с команд бот получает только сообщения, которые начинаются с его команд, а ответ сотрудника обычно без команды. Включите Игнорировать свои сообщения, чтобы бот не получал события о собственных ответах. Подробнее — в разделе Сообщения.

Скопируйте Signing secret

Секрет появится на вкладке после включения вебхука. Обработчик проверяет им подпись каждого запроса, передайте его в переменной окружения PACHCA_SIGNING_SECRET.

Вкладка «Исходящий вебхук» после включения: этой странице нужны «Новые сообщения», а «Нажатие кнопок» пригодится для кнопок и форм

Напишите обработчик

Обработчик принимает событие, проверяет подпись и время отправки и сразу отвечает 200: Пачка ждёт ответа 5 секунд, все сроки — в разделе Сроки ответа. Почему подпись считают от сырого тела запроса и как проверить IP-адрес отправителя, описано в разделе Безопасность.

После ответа обработчик пишет сотруднику туда, где тот написал:

  • сообщение из беседы или канала — тредом под ним: метод POSTНовый тред открывает тред или возвращает уже открытый, а POSTНовое сообщение с entity_type: "thread" пишет в него
  • сообщение из треда или личных сообщений — туда же, с теми же entity_type и entity_id

Подробнее — в разделе Куда отвечать. Свои сообщения обработчик пропускает по id бота из метода GETСвой профиль, так что бот не отвечает сам себе, даже если Игнорировать свои сообщения выключено.

Пример отвечает на каждое новое сообщение, поэтому проверяйте его в отдельной беседе. Когда отвечать в рабочих чатах, описано в разделе Когда отвечать.

TypeScript

Пример работает на Node.js 18 или новее и принимает события на порту 3000 по пути /webhook.

bot.ts
import crypto from "node:crypto"import express from "express" const API = "https://api.pachca.com/api/shared/v1"const TOKEN = process.env.PACHCA_BOT_TOKEN ?? "" // токен ботаconst SIGNING_SECRET = process.env.PACHCA_SIGNING_SECRET ?? "" // Signing secret с вкладки «Исходящий вебхук»if (!TOKEN) throw new Error("Задайте PACHCA_BOT_TOKEN") // Запрос к API Пачки: возвращает поле data из ответаasync function api(method: string, path: string, body?: unknown) {  const response = await fetch(`${API}${path}`, {    method,    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },    body: body === undefined ? undefined : JSON.stringify(body),  })  if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`)  return response.status === 204 ? null : (await response.json()).data} type PachcaEvent = {  type: string  event: string  id: number  entity_type: "discussion" | "thread" | "user"  entity_id: number  user_id: number} let botId: number | undefined // id бота, узнаём методом «Свой профиль» при первом событии async function handleEvent(event: PachcaEvent) {  if (event.type !== "message" || event.event !== "new") return  botId ??= (await api("GET", "/profile")).id  if (event.user_id === botId) return // своё сообщение   // Из беседы или канала отвечаем тредом под сообщением, из треда и личных сообщений — туда же  let target = { entity_type: event.entity_type, entity_id: event.entity_id }  if (event.entity_type === "discussion") {    const thread = await api("POST", `/messages/${event.id}/thread`) // если тред уже есть, вернётся он    target = { entity_type: "thread", entity_id: thread.id }  }  await api("POST", "/messages", { message: { ...target, content: "Получил ваше сообщение, разбираюсь" } })} const app = express() // express.raw: подпись считают от сырого тела запросаapp.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {  const expected = crypto.createHmac("sha256", SIGNING_SECRET).update(req.body).digest()  const received = Buffer.from(String(req.headers["pachca-signature"] ?? ""), "hex")  if (!SIGNING_SECRET || received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {    return res.status(401).end() // секрет не задан или подпись не совпала: запрос не принимаем  }   const event = JSON.parse(req.body.toString())  if (Math.abs(Date.now() / 1000 - event.webhook_timestamp) > 60) {    return res.status(401).end() // запрос старше минуты: его могли перехватить и отправить повторно  }   res.status(200).end() // отвечаем сразу, а сообщение отправляем после ответа  handleEvent(event).catch(console.error)}) app.listen(3000)
Запуск
npm install expressexport PACHCA_BOT_TOKEN="токен бота" PACHCA_SIGNING_SECRET="Signing secret"npx tsx bot.ts

Python

Пример принимает события на порту 3000 по пути /webhook. Сообщение он отправляет в фоне через threading.Thread, чтобы ответить Пачке сразу.

bot.py
import hashlibimport hmacimport jsonimport osimport threadingimport time import httpxfrom flask import Flask, request API = "https://api.pachca.com/api/shared/v1"HEADERS = {"Authorization": f"Bearer {os.environ['PACHCA_BOT_TOKEN']}"}  # токен ботаSIGNING_SECRET = os.environ.get("PACHCA_SIGNING_SECRET", "")  # Signing secret с вкладки «Исходящий вебхук»  def api(method, path, body=None):    """Запрос к API Пачки: возвращает поле data из ответа."""    response = httpx.request(method, API + path, headers=HEADERS, json=body)    response.raise_for_status()    return response.json()["data"] if response.content else None  bot_id = None  # id бота, узнаём методом «Свой профиль» при первом событии  def handle_event(event):    global bot_id    if event["type"] != "message" or event["event"] != "new":        return    if bot_id is None:        bot_id = api("GET", "/profile")["id"]    if event["user_id"] == bot_id:        return  # своё сообщение     # Из беседы или канала отвечаем тредом под сообщением, из треда и личных сообщений — туда же    target = {"entity_type": event["entity_type"], "entity_id": event["entity_id"]}    if event["entity_type"] == "discussion":        thread = api("POST", f"/messages/{event['id']}/thread")  # если тред уже есть, вернётся он        target = {"entity_type": "thread", "entity_id": thread["id"]}    api("POST", "/messages", {"message": {**target, "content": "Получил ваше сообщение, разбираюсь"}})  app = Flask(__name__)  @app.post("/webhook")def webhook():    raw_body = request.get_data()  # подпись считают от сырого тела запроса    expected = hmac.new(SIGNING_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()    received = request.headers.get("Pachca-Signature", "")    if not SIGNING_SECRET or not hmac.compare_digest(expected.encode(), received.encode()):        return "", 401  # секрет не задан или подпись не совпала: запрос не принимаем     event = json.loads(raw_body)    if abs(time.time() - event["webhook_timestamp"]) > 60:        return "", 401  # запрос старше минуты: его могли перехватить и отправить повторно     # Отвечаем сразу, а сообщение отправляем в фоне    threading.Thread(target=handle_event, args=(event,)).start()    return "", 200  if __name__ == "__main__":    app.run(port=3000)
Запуск
pip install flask httpxexport PACHCA_BOT_TOKEN="токен бота" PACHCA_SIGNING_SECRET="Signing secret"python bot.py

Проверка

Запустите обработчик и ответьте в треде под сообщением, которое бот отправил в разделе Отправьте сообщение, или напишите в беседе: бот ответит в тред. Если ответа нет:

  • Обработчик не получает запросов — проверьте, что адрес в Webhook URL заканчивается на /webhook, бот состоит в беседе, отмечено Новые сообщения и выбран режим Любые сообщения
  • Обработчик отвечает 401 — Signing secret не задан в переменной окружения или отличается от секрета на вкладке, или часы сервера расходятся с точным временем больше чем на минуту
  • Запрос к API отвечает 403 с кодом insufficient_scope — токену не хватает права из раздела Подготовьте бота, его добавляют в меню токена

Разработка на своей машине

Пачка отправляет события на публичный адрес http или https, подробнее в разделе Общие настройки. Пока обработчик работает на вашей машине, есть два способа:

  • Туннель — ngrok, Cloudflare Tunnel или другой туннель выдаёт порту 3000 временный публичный адрес: укажите его с путём /webhook в Webhook URL, и обработчик из примера заработает без изменений
  • Поток событий — бот сам подключается к Пачке и получает события по открытому соединению, а входящие запросы и проверка подписи ему не нужны

Как настроить оба способа и почему для разработки удобен отдельный бот, описано в разделе Разработка и запуск обработчика.

Для потока включите на вкладке Исходящий вебхук настройку Сохранять историю событий и добавьте токену права webhooks:events:read и webhooks:events:delete. События и режим выберите, как в разделе Включите исходящий вебхук. Эти настройки появляются на вкладке после включения вебхука, а боту без адреса их задают через API, подробнее в разделах Поллинг и Через API. Если адрес указан, но Пачка не может к нему подключиться, событие придёт в поток ещё раз при каждой повторной попытке доставки, подробнее в разделе История при работающем вебхуке.

Код ниже получает ссылку методом GETПоток событий, подключается к ней и передаёт каждое событие той же функции обработчика. Ссылка открывается без токена, поэтому храните её как секрет. Переменная PACHCA_SIGNING_SECRET потоку не нужна.

В bot.ts замените строку app.listen(3000) этим кодом и установите пакет командой npm install eventsource:

bot.ts: поток вместо сервера
import { EventSource } from "eventsource" api("GET", "/webhooks/events/stream").then(({ url }) => {  const stream = new EventSource(url)  // Событие бота приходит сообщением без имени: запись истории с id, event_type, payload и created_at  stream.addEventListener("message", (e) => {    const record = JSON.parse(e.data)    handleEvent(record.payload)      .then(() => api("DELETE", `/webhooks/events/${record.id}`)) // обработанное событие убираем из истории      .catch(console.error)  })})

В bot.py замените блок if __name__ == "__main__": этим кодом и установите пакет командой pip install httpx-sse:

bot.py: поток вместо сервера
from httpx_sse import connect_sse  def listen():    stream = api("GET", "/webhooks/events/stream")  # ссылка на поток и срок её действия    with httpx.Client(timeout=None) as client, connect_sse(client, "GET", stream["url"]) as source:        for sse in source.iter_sse():            if sse.event != "message":                continue  # служебные сообщения подключения            record = sse.json()  # запись истории событий: id, event_type, payload, created_at            handle_event(record["payload"])            api("DELETE", f"/webhooks/events/{record['id']}")  # обработанное событие убираем из истории  if __name__ == "__main__":    listen()

Этот код работает, пока действует ссылка, её срок приходит в поле expires_at. События, которые пришли до подключения, он не забирает. Как дочитывать историю и менять ссылку без потери событий, показано в разделе Пример подключения.

Что дальше

  • Ответ на сообщение сервиса — как связать ответ сотрудника с заявкой или уведомлением, которое отправил бот, описано в разделе Ответы на сообщения сервиса
  • Упоминания и команды — бот в рабочем чате отвечает, когда к нему обращаются, подробнее в разделах Когда отвечать и Команды
  • Контекст разговора — историю треда и сообщение, к которому он открыт, бот читает методами из раздела Контекст разговора
  • Повторы событий — одно событие изредка приходит дважды, ключ для отсева описан в разделе Идемпотентная обработка
  • Кнопки и формы — бот с командой, кнопками и формой целиком собран в разделе Интерактивный бот
  • Запуск в работу — что проверить перед тем, как указать адрес рабочего сервера, перечислено в разделе Чек-лист перед запуском в работу
  • SDK — типизированный клиент API и типы событий есть в SDK для TypeScript, Python и других языков