# WhatsApp-бот

Людина пише боту свій знак, наприклад `leo`, і отримує гороскоп на сьогодні. На відміну від [Telegram](/integrations/telegram/), WhatsApp Cloud API сам надсилає повідомлення на ваш вебхук, тому серверу потрібна публічна адреса з HTTPS.

## 1. Що знадобиться

1. Застосунок на [developers.facebook.com](https://developers.facebook.com/apps/) типу Business з доданим продуктом **WhatsApp**. На сторінці **API Setup** є тестовий номер, його **Phone number ID** і тимчасовий токен доступу; для роботи довше доби створіть постійний токен системного користувача.
2. **App secret** з **App settings → Basic**: ним Meta підписує кожне повідомлення на вебхук.
3. Ключ AstroWay **Тільки сервер** з областю `public/horoscope/*`, як описано в розділі [Ключ для автоматизації](/integrations/). Ендпоінт кредитів не витрачає.

```bash frame="terminal"
export ASTROWAY_API_KEY="aw_live_..."
export WHATSAPP_TOKEN="EAAG..."
export WHATSAPP_PHONE_NUMBER_ID="1234567890"
export WHATSAPP_APP_SECRET="..."
export WHATSAPP_VERIFY_TOKEN="будь-який-рядок-який-ви-вигадаєте"
```

## 2. Код

Збережіть як `whatsapp.mjs`:

```js title="whatsapp.mjs"
// Node 22 or newer, no dependencies.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const GRAPH = 'https://graph.facebook.com/v23.0';
const ASTROWAY = 'https://api.astroway.info/v1';
const LANG = 'uk';
// One text per sign per day: at most 12 calls a day, whatever the traffic.
const cache = new Map();
const SIGNS = ['aries', 'taurus', 'gemini', 'cancer', 'leo', 'virgo',
  'libra', 'scorpio', 'sagittarius', 'capricorn', 'aquarius', 'pisces'];

async function answer(text) {
  const sign = text.trim().toLowerCase();
  if (!SIGNS.includes(sign)) return `Send your sign, for example: leo\n${SIGNS.join(', ')}`;
  const key = `${new Date().toISOString().slice(0, 10)} ${sign}`;
  if (!cache.has(key)) {
    const res = await fetch(`${ASTROWAY}/public/horoscope/daily?sign=${sign}&lang=${LANG}`, {
      headers: { 'X-Api-Key': process.env.ASTROWAY_API_KEY },
    });
    const json = await res.json();
    if (!json.ok) return `AstroWay: ${json.error.message}`;
    cache.set(key, `${json.data.date}, ${sign}\n\n${json.data.horoscope}`.slice(0, 4000));
  }
  return cache.get(key);
}

function signedByMeta(raw, header) {
  const expected = 'sha256=' + createHmac('sha256', process.env.WHATSAPP_APP_SECRET).update(raw).digest('hex');
  return header?.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

async function send(to, body) {
  await fetch(`${GRAPH}/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body } }),
  });
}

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');

  // Meta checks the webhook once, when you save its URL.
  if (req.method === 'GET') {
    const ok = url.searchParams.get('hub.mode') === 'subscribe'
      && url.searchParams.get('hub.verify_token') === process.env.WHATSAPP_VERIFY_TOKEN;
    res.writeHead(ok ? 200 : 403).end(ok ? url.searchParams.get('hub.challenge') : '');
    return;
  }

  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const raw = Buffer.concat(chunks);
  if (!signedByMeta(raw, req.headers['x-hub-signature-256'])) {
    res.writeHead(401).end();
    return;
  }
  // Answer Meta first: a slow 200 makes it deliver the same message again.
  res.writeHead(200).end();

  const messages = JSON.parse(raw).entry?.[0]?.changes?.[0]?.value?.messages ?? [];
  for (const message of messages) {
    if (message.type !== 'text') continue;
    await send(message.from, await answer(message.text.body));
  }
}).listen(process.env.PORT ?? 3000);
```

Запустіть `node whatsapp.mjs` за HTTPS-проксі або на хостингу, який дає публічну адресу.

## 3. Підключіть вебхук

1. У застосунку відкрийте **WhatsApp → Configuration → Webhook → Edit**.
2. **Callback URL**: адреса вашого сервера, наприклад `https://bot.example.com/webhook`. **Verify token**: те саме значення, що в `WHATSAPP_VERIFY_TOKEN`. Meta одразу надішле `GET` з `hub.challenge`, і код поверне його назад.
3. У **Webhook fields** підпишіться на `messages`.
4. Напишіть `leo` на тестовий номер із телефону, доданого в список отримувачів на сторінці **API Setup**.

## Що варто знати

- **Підпис обов'язковий.** Адреса вебхука публічна, тож без перевірки `X-Hub-Signature-256` будь-хто міг би змусити бота писати людям. Запит без правильного підпису отримує `401`.
- **Вільний текст лише у відповідь.** Cloud API дозволяє довільне повідомлення протягом 24 годин після останнього повідомлення людини. Розсилка гороскопу щоранку тим, хто давно не писав, потребує затвердженого шаблону повідомлення.
- **Кеш у коді** тримає не більше за 12 запитів до API на добу. Ключ Free на `/v1/public/*` рахується в той самий ліміт, що й запит без ключа, 30 запитів на годину з IP вашого сервера, а неправильний ключ мовчки дає саме цей анонімний ліміт замість `401`.
- **Версія Graph API** у `GRAPH` це будь-яка чинна версія. Meta підтримує кожну щонайменше два роки, тож коли `v23.0` застаріє, змініть рядок.

**Note:**
Перевірено 2026-11-07 проти продакшену AstroWay: перевірка вебхука (правильний і неправильний токен), повідомлення з правильним і підробленим підписом, відповідь на знак і на довільний текст. Meta у перевірці підмінено локальним сервером: справжній номер WhatsApp ми не підключали, тож формат вхідного повідомлення взято з документації Cloud API.
