Botzo دليل المطوّرين
أقسام الدليل: أمثلة كاملة

أمثلة كاملة

أمثلة جاهزة للنسخ تغطي أشهر الاستخدامات. غيّر المفاتيح والروابط وابدأ.

متجر: إشعار الطلب ومتابعته (Node.js)

سيرفر Express واحد يرسل إشعار شحن، ويستقبل ضغطات الأزرار وحالات الرسائل.

// npm i express
import express from 'express';
import crypto from 'node:crypto';

const BOTZO = 'https://botzo.net/api';
const headers = {
  Authorization: `Bearer ${process.env.BOTZO_API_KEY}`,
  Accept: 'application/json',
  'Content-Type': 'application/json',
};

// 1) إرسال إشعار الشحن: نص بأزرار لو النافذة مفتوحة، وإلا قالب
export async function notifyShipped(order) {
  const send = (path, body) => fetch(`${BOTZO}${path}`, {
    method: 'POST',
    headers: { ...headers, 'Idempotency-Key': `order-${order.id}-shipped` },
    body: JSON.stringify(body),
  }).then(async (res) => ({ ok: res.ok, body: await res.json() }));

  let result = await send('/send', {
    phone: order.phone,
    message: `طلبك رقم ${order.id} في الطريق 🚚`,
    buttons: [{ id: `track_${order.id}`, title: 'تتبع الطلب' }],
  });

  if (!result.ok && result.body.code === 'outside_24h_window') {
    result = await send('/send/template', {
      phone: order.phone,
      template: {
        name: 'order_shipped',
        language: { code: 'ar' },
        components: [{ type: 'body', parameters: [
          { type: 'text', text: order.customerName },
          { type: 'text', text: String(order.id) },
        ] }],
      },
    });
  }

  if (!result.ok) throw new Error(`${result.body.code}: ${result.body.message}`);
  await db.orders.update(order.id, { whatsappMessageId: result.body.message_id });
}

// 2) استقبال الأحداث
const app = express();
const seen = new Set(); // في الإنتاج استخدم Redis أو جدولاً

app.post('/botzo/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const ts = req.get('X-Botzo-Timestamp') ?? '';
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.BOTZO_WEBHOOK_SECRET)
    .update(`${ts}.${req.body}`).digest('hex');
  const sig = req.get('X-Botzo-Signature') ?? '';
  if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);

  const event = JSON.parse(req.body);
  if (seen.has(event.id)) return;
  seen.add(event.id);

  if (event.type === 'message.received' && event.data.button?.id?.startsWith('track_')) {
    const orderId = event.data.button.id.replace('track_', '');
    const order = await db.orders.find(orderId);
    await fetch(`${BOTZO}/send`, {
      method: 'POST', headers,
      body: JSON.stringify({ phone: event.data.from, message: `رابط التتبع: ${order.trackingUrl}` }),
    });
  }

  if (event.type === 'message.status') {
    await db.orders.updateWhere(
      { whatsappMessageId: event.data.message_id },
      { whatsappStatus: event.data.status },
    );
  }
});

app.listen(3000);

Laravel: خدمة إرسال ومستقبل أحداث

// config/services.php
'botzo' => [
    'url' => env('BOTZO_URL', 'https://botzo.net/api'),
    'key' => env('BOTZO_API_KEY'),
    'webhook_secret' => env('BOTZO_WEBHOOK_SECRET'),
],
// app/Services/Botzo.php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class Botzo
{
    private function client(?string $idempotencyKey = null)
    {
        return Http::baseUrl(config('services.botzo.url'))
            ->withToken(config('services.botzo.key'))
            ->acceptJson()
            ->timeout(30)
            ->retry(3, fn ($attempt) => 1000 * 2 ** $attempt, fn ($e) => in_array(optional($e->response)->status(), [409, 429, 500, 502, 503]), throw: false)
            ->withHeaders(array_filter(['Idempotency-Key' => $idempotencyKey]));
    }

    public function sendText(string $phone, string $text, ?string $key = null): array
    {
        return $this->client($key)->post('/send', ['phone' => $phone, 'message' => $text])->json();
    }

    public function sendTemplate(string $phone, string $name, array $bodyParams, string $lang = 'ar', ?string $key = null): array
    {
        return $this->client($key)->post('/send/template', [
            'phone' => $phone,
            'template' => [
                'name' => $name,
                'language' => ['code' => $lang],
                'components' => [[
                    'type' => 'body',
                    'parameters' => array_map(fn ($v) => ['type' => 'text', 'text' => (string) $v], $bodyParams),
                ]],
            ],
        ])->json();
    }
}
// routes/web.php (أضف /botzo/webhook لاستثناءات CSRF)
Route::post('/botzo/webhook', function (Illuminate\Http\Request $request) {
    $ts = (string) $request->header('X-Botzo-Timestamp');
    $body = $request->getContent();
    $expected = 'sha256='.hash_hmac('sha256', $ts.'.'.$body, config('services.botzo.webhook_secret'));
    abort_unless(hash_equals($expected, (string) $request->header('X-Botzo-Signature')), 401);
    abort_if(abs(time() - (int) $ts) > 300, 401);

    $event = json_decode($body, true);
    if (Cache::add('botzo-event:'.$event['id'], true, now()->addDay())) {
        dispatch(new App\Jobs\HandleBotzoEvent($event));
    }

    return response()->noContent();
});

Flutter: شاشة شات موتشي كاملة (مفتاح عام)

// pubspec.yaml: http, shared_preferences
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

const baseUrl = 'https://botzo.net/api/v1/assistant';
const publicKey = 'pk_your_public_key'; // المفتاح العام فقط داخل التطبيق

class MochiClient {
  String? conversationId;
  String? token;

  Map<String, String> get _headers => {
        'Authorization': 'Bearer $publicKey',
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        if (token != null) 'X-Conversation-Token': token!,
      };

  Future<List<Map<String, dynamic>>> open() async {
    final prefs = await SharedPreferences.getInstance();
    token = prefs.getString('mochi_token');
    final res = await http.post(Uri.parse('$baseUrl/conversations'),
        headers: _headers, body: jsonEncode({'locale': 'ar'}));
    final body = jsonDecode(res.body);
    if (body['conversation_token'] != null) {
      token = body['conversation_token'];
      await prefs.setString('mochi_token', token!);
    }
    conversationId = body['conversation_id'];
    return List<Map<String, dynamic>>.from(body['messages']);
  }

  Future<Map<String, dynamic>> send(String text) async {
    final res = await http
        .post(Uri.parse('$baseUrl/conversations/$conversationId/messages'),
            headers: _headers, body: jsonEncode({'text': text}))
        .timeout(const Duration(seconds: 45));
    return jsonDecode(res.body);
  }

  Future<List<Map<String, dynamic>>> since(int afterId) async {
    final res = await http.get(
        Uri.parse('$baseUrl/conversations/$conversationId/messages?after=$afterId'),
        headers: _headers);
    return List<Map<String, dynamic>>.from(jsonDecode(res.body)['messages']);
  }
}

class MochiChatScreen extends StatefulWidget {
  const MochiChatScreen({super.key});
  @override
  State<MochiChatScreen> createState() => _MochiChatScreenState();
}

class _MochiChatScreenState extends State<MochiChatScreen> {
  final client = MochiClient();
  final input = TextEditingController();
  final messages = <Map<String, dynamic>>[];
  bool typing = false;

  @override
  void initState() {
    super.initState();
    client.open().then((history) => setState(() => messages.addAll(history)));
  }

  Future<void> sendMessage() async {
    final text = input.text.trim();
    if (text.isEmpty || typing) return;
    input.clear();
    setState(() {
      messages.add({'from': 'user', 'text': text});
      typing = true;
    });
    final body = await client.send(text);
    setState(() {
      typing = false;
      messages.addAll(List<Map<String, dynamic>>.from(body['replies'] ?? []));
      if (body['awaiting_team'] == true) {
        messages.add({'from': 'note', 'text': 'سيرد عليك أحد أفراد الفريق قريباً'});
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Directionality(
      textDirection: TextDirection.rtl,
      child: Scaffold(
        appBar: AppBar(title: const Text('موتشي')),
        body: Column(children: [
          Expanded(
            child: ListView(
              padding: const EdgeInsets.all(12),
              children: [
                for (final m in messages)
                  Align(
                    alignment: m['from'] == 'user' ? Alignment.centerRight : Alignment.centerLeft,
                    child: Container(
                      margin: const EdgeInsets.symmetric(vertical: 4),
                      padding: const EdgeInsets.all(12),
                      decoration: BoxDecoration(
                        color: m['from'] == 'user' ? Colors.green.shade100 : Colors.grey.shade200,
                        borderRadius: BorderRadius.circular(14),
                      ),
                      child: Text(m['text'] ?? ''),
                    ),
                  ),
                if (typing) const Padding(padding: EdgeInsets.all(8), child: Text('موتشي يكتب…')),
              ],
            ),
          ),
          SafeArea(
            child: Row(children: [
              Expanded(child: TextField(controller: input, decoration: const InputDecoration(hintText: 'اكتب رسالتك…'))),
              IconButton(icon: const Icon(Icons.send), onPressed: typing ? null : sendMessage),
            ]),
          ),
        ]),
      ),
    );
  }
}

Swift (SwiftUI): موتشي بمفتاح عام

import SwiftUI

struct MochiMessage: Identifiable, Decodable { let id: Int?; let from: String; let text: String
    var stableId: String { "\(id ?? 0)-\(text)" }
}

@MainActor
final class MochiChat: ObservableObject {
    @Published var messages: [MochiMessage] = []
    @Published var typing = false
    private let base = "https://botzo.net/api/v1/assistant"
    private let key = "pk_your_public_key"
    private var conversationId = ""
    private var token: String? = UserDefaults.standard.string(forKey: "mochi_token")

    private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil) async throws -> [String: Any] {
        var r = URLRequest(url: URL(string: base + path)!)
        r.httpMethod = method
        r.timeoutInterval = 45
        r.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
        r.setValue("application/json", forHTTPHeaderField: "Accept")
        r.setValue("application/json", forHTTPHeaderField: "Content-Type")
        if let token { r.setValue(token, forHTTPHeaderField: "X-Conversation-Token") }
        if let body { r.httpBody = try JSONSerialization.data(withJSONObject: body) }
        let (data, _) = try await URLSession.shared.data(for: r)
        return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
    }

    private func decode(_ value: Any?) -> [MochiMessage] {
        guard let value, let data = try? JSONSerialization.data(withJSONObject: value) else { return [] }
        return (try? JSONDecoder().decode([MochiMessage].self, from: data)) ?? []
    }

    func open() async {
        let body = try? await request("/conversations", method: "POST", body: ["locale": "ar"])
        if let newToken = body?["conversation_token"] as? String {
            token = newToken
            UserDefaults.standard.set(newToken, forKey: "mochi_token")
        }
        conversationId = body?["conversation_id"] as? String ?? ""
        messages = decode(body?["messages"])
    }

    func send(_ text: String) async {
        messages.append(MochiMessage(id: nil, from: "user", text: text))
        typing = true
        let body = try? await request("/conversations/\(conversationId)/messages", method: "POST", body: ["text": text])
        typing = false
        messages += decode(body?["replies"])
    }
}

Kotlin (Android): موتشي بمفتاح عام

// build.gradle: implementation("com.squareup.okhttp3:okhttp:4.12.0")
class MochiRepository(context: Context) {
    private val client = OkHttpClient.Builder().callTimeout(45, TimeUnit.SECONDS).build()
    private val prefs = context.getSharedPreferences("mochi", Context.MODE_PRIVATE)
    private val base = "https://botzo.net/api/v1/assistant"
    private val key = "pk_your_public_key"
    private var conversationId = ""

    private fun call(path: String, json: JSONObject? = null): JSONObject {
        val builder = Request.Builder().url(base + path)
            .header("Authorization", "Bearer $key")
            .header("Accept", "application/json")
        prefs.getString("token", null)?.let { builder.header("X-Conversation-Token", it) }
        if (json != null) builder.post(json.toString().toRequestBody("application/json".toMediaType()))
        client.newCall(builder.build()).execute().use { return JSONObject(it.body!!.string()) }
    }

    suspend fun open(): JSONArray = withContext(Dispatchers.IO) {
        val body = call("/conversations", JSONObject().put("locale", "ar"))
        body.optString("conversation_token").takeIf { it.isNotEmpty() }?.let {
            prefs.edit().putString("token", it).apply()
        }
        conversationId = body.getString("conversation_id")
        body.getJSONArray("messages")
    }

    suspend fun send(text: String): JSONObject = withContext(Dispatchers.IO) {
        call("/conversations/$conversationId/messages", JSONObject().put("text", text))
    }

    suspend fun since(afterId: Int): JSONArray = withContext(Dispatchers.IO) {
        call("/conversations/$conversationId/messages?after=$afterId").getJSONArray("messages")
    }
}

موقع ويب: موتشي عبر سيرفرك (مفتاح سري)

المستخدمون مسجلون في موقعك، فسيرفرك يحدد هويتهم ويخفي المفتاح السري:

// سيرفرك (Express): وسيط بين المتصفح وبوتزو
app.post('/api/support/messages', requireLogin, express.json(), async (req, res) => {
  const headers = {
    Authorization: `Bearer ${process.env.BOTZO_MOCHI_KEY}`,
    Accept: 'application/json',
    'Content-Type': 'application/json',
  };
  const open = await fetch('https://botzo.net/api/v1/assistant/conversations', {
    method: 'POST', headers,
    body: JSON.stringify({ user: { id: String(req.user.id), name: req.user.name } }),
  }).then((r) => r.json());

  const reply = await fetch(`https://botzo.net/api/v1/assistant/conversations/${open.conversation_id}/messages`, {
    method: 'POST', headers, body: JSON.stringify({ text: String(req.body.text).slice(0, 500) }),
  });
  res.status(reply.status).json(await reply.json());
});
// المتصفح: لا يرى أي مفتاح
const res = await fetch('/api/support/messages', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ text: 'كم سعر باقة النمو؟' }),
});
const { replies } = await res.json();

Python: حملة من نظامك

import os, requests

API = "https://botzo.net/api"
HEADERS = {"Authorization": f"Bearer {os.environ['BOTZO_API_KEY']}", "Accept": "application/json"}

# 1) أنشئ مجموعة وأضف العملاء
group = requests.post(f"{API}/contact-groups", headers=HEADERS, json={"name": "عملاء رمضان"}).json()["id"]
for customer in customers:
    requests.post(f"{API}/contacts", headers=HEADERS, json={
        "first_name": customer["name"], "phone": customer["phone"], "group": [group],
        "metadata": {"city": customer["city"]},
    })

# 2) اختر القالب
templates = requests.get(f"{API}/templates?per_page=100", headers=HEADERS).json()["data"]
template = next(t for t in templates if t["name"] == "ramadan_offer")

# 3) أطلق الحملة غداً 8 مساءً بتوقيت المؤسسة
campaign = requests.post(f"{API}/campaigns", headers=HEADERS, json={
    "name": "عرض رمضان",
    "template": template["uuid"],
    "contacts": group,
    "skip_schedule": False,
    "time": "2026-11-01 20:00",
    "body": {"parameters": [
        {"type": "text", "selection": "dynamic", "value": "first name"},
        {"type": "text", "selection": "dynamic", "value": "field:city"},
    ]},
}).json()
print(campaign["data"]["contacts_count"], campaign["data"]["scheduled_at_utc"])