بناء التطبيق

تطبيقات الإشعار (Notification)

تطبيق الإشعار يُسجَّل كقناة قابلة للتوصيل تستهلكها المنصة عبر واجهة موحَّدة. أنت تبني المزوِّد مرة، والمنصة تستدعيه بـ send(recipient, message) بصرف النظر عن القناة الفعلية (واتساب، SMS، Push، Slack، ...).

الـ manifest block

{
  "integrationType": "notification",
  "notification": {
    "channels": ["whatsapp", "sms"],
    "delivery": "api",
    "api": {
      "send_endpoint": "https://your-app.example/notifications/send",
      "auth": "hmac"
    },
    "embedded": {
      "provider_class": "Modules\\AcmeSms\\Providers\\AcmeSmsProvider"
    },
    "capabilities": {
      "supports_templates": true,
      "supports_media": false,
      "supports_bulk": true,
      "max_bulk_recipients": 1000
    },
    "settings_ui": {
      "has_settings_page": true,
      "livewire_component": "partner-apps.acme-sms.settings"
    }
  }
}

قواعد التحقق التي تفرضها المنصة على manifest الإشعار:

  • notification.channels مصفوفة غير فارغة من قيم معروفة: whatsapp, sms, push, slack, email, telegram, voice.
  • notification.delivery لازم تكون api أو embedded أو hybrid.
  • إذا delivery=api أو hybrid ⇐ notification.api.send_endpoint لازم يكون HTTPS.
  • إذا delivery=embedded أو hybrid ⇐ notification.embedded.provider_class لازم يكون FQCN صالح (مثل Modules\Foo\Providers\Bar).
  • لا يُسمح بـ notification block إلا حين integrationType=notification.

الـ scopes (تُمنح تلقائياً)

عند اختيار integrationType=notification من نموذج الإنشاء، المنصة تُلحق تلقائياً بمنحات تطبيقك:

  • notifications.providers.send (إجباري — يسمح بالإرسال)
  • notifications.logs.read (اختياري — قراءة سجل الإرسال)

لا تحتاج اختيار شيء من picker الصلاحيات لو تطبيقك بحت إشعار.

مسار 1: delivery=api (موصى به للبدء السريع)

العقد بين المنصة وتطبيقك

تستقبل من okta-web:

POST https://your-app.example/notifications/send
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Delivery-Id: <uuid>

{
  "channel": "whatsapp",
  "recipient_identifier": "+966500000000",
  "message": {
    "body": "غاب سارة يوم 2026-09-15",
    "title": "غياب طالب",
    "template_id": "student_absent_v2",
    "variables": { "student_name": "سارة", "date": "2026-09-15" },
    "media": []
  },
  "metadata": {
    "notification_key": "okta-attendance.student_absent",
    "app_slug": "okta-attendance",
    "delivery_id": 48213
  }
}

metadata هي هوية الإشعار — راجع القسم التالي؛ وmessage.variables هي متغيّرات الكتالوج كما أرسلها التطبيق لا الجسم المُصيَّر وحده، و message.template_id ما اختارته الجهة إن اختارت (أو null).

هوية الإشعار — ما يصل مزوّدك عن كل رسالة

لماذا هذا الفصل موجود: رقم واتساب رسمي (Cloud API) لا يسلّم رسالةً تبدأها الجهة إلا قالباً معتمداً لدى Meta. مزوّدٌ يلفّ كل إشعار في قالب افتراضي واحد يبقى هناك ما لم يعرف أيَّ إشعار يحمل — ولم يكن في الحمولة ما يقوله. صار كل نداء send() — من كل قناة، ومن زرّ «إرسال تجريبي» عند الجهة — يحمل في metadata ثلاثة مفاتيح ثابتة:

المفتاح مثال معناه
notification_key okta-attendance.student_absent مفتاح الكتالوج للإشعار — ما تختار به القالب المناسب
app_slug okta-attendance التطبيق المُرسِل — لا مزوّدك أنت
delivery_id 48213 صفّ partner_notification_deliveries الذي يخصّ هذا الإرسال — ما تعيده في تذكرة دعم أو تربط به ردّ Meta

ومعها:

  • variables — متغيّرات الكتالوج كما أرسلها التطبيق (student_name، date، …) لا الجسم المُصيَّر وحده. اجعلها هي ما يملأ خانات القالب؛ وbody هو النصّ الجاهز لمن لا يعمل بالقوالب أو لقالب عام بخانة واحدة.
  • templateId — ما اختارته الجهة لهذا الإشعار من قوالبك (القسم التالي)، أو null فتقرّر أنت: قالبٌ تربطه بـnotification_key، أو قالبك الافتراضي.
  • test: true يظهر فقط حين يأتي النداء من زرّ التجربة عند الجهة؛ الهوية الثلاثية تصل معه كاملةً، فاختر للتجربة ما ستختاره للإرسال الحقيقي — تجربةٌ تنجح بقالب افتراضي وإرسالٌ يفشل بقالب لم يُعتمد هما ما جاء هذا الفصل ليمنعه.

فخّ يستحقّ سطره: على عامل الطابور يعمل مزوّدك داخل سياق تثبيته هو. فلو سألت AppContextManager «أي تطبيق أنا؟» سمعت اسمك أنت لا اسم المُرسِل. اسم المُرسِل يصلك في metadata.app_slug وحدها.

مسار POST /api/apps/notifications/send الخام (تطبيق يرسل عبرك مباشرةً بلا كتالوج) يحمل app_slug وrecipient_type فقط — لا مفتاح ولا صفّ تسليم هناك.

قوالب معتمدة تختارها الجهة — supports_templates

إن كان مزوّدك يعلن notification.capabilities.supports_templates: true، تعرض الجهة في «تخصيص الرسالة» على /settings/notifications حقلاً إضافياً لكل إشعار: قالب المزوّد المعتمد. ما تختاره يصلك في كل إرسال NotificationPayload::$templateId (وmessage.template_id على الـ api).

  • مزوّد مدمج يستطيع أن يدرج قوالبه فتظهر قائمةً، بتطبيق عقد ثانٍ اختياري بجانب PartnerNotificationProvider:

    use App\Contracts\ListsNotificationTemplates;
    
    final class WaCloudProvider implements PartnerNotificationProvider, ListsNotificationTemplates
    {
        /** @return list<array{id: string, name: string, description?: ?string}> */
        public function templates(string $channel): array
        {
            // قوالب الرقم المعتمدة لدى Meta — تُنادى داخل سياق تثبيتك،
            // فرقم الجهة هو رقمها. لا ترمِ؛ أعِد [] عند التعذّر.
            return $this->meta->approvedTemplates($channel);
        }
    }
    

    والقائمة مغلقة: معرّف خارجها يُرفض عند الحفظ، فلا يصل Meta قالبٌ غير موجود.

  • مزوّد api لا صنف له هنا، فالجهة تكتب معرّف القالب كما هو عندك.

  • لا supports_templates ⇐ لا حقل، وtemplateId دائماً null.

المنطق عندك لا يتغيّر بوجود الاختيار: templateId أولاً إن وصل، وإلا ما تربطه بـnotification_key، وإلا الافتراضي.

كتالوج إشعارات التطبيقات المثبَّتة — مصدرك لبناء القوالب

القالب المعتمد يُبنى قبل أول إرسال، ولا تستطيع بناءه لمفتاح لا تعرفه. ولذلك للمزوّد نقطة تقرأ كتالوج إشعارات كل التطبيقات المثبَّتة على الجهة التي ثُبِّت فيها — بنطاقه هو notifications.providers.send، بلا نطاق إضافي:

GET /api/apps/notifications/catalog?channel=whatsapp
Authorization: Bearer <installation token>
{
  "tenant_id": 4821,
  "apps": [
    {
      "app_slug": "okta-attendance",
      "display_name": "اوكتا حضور",
      "installation_id": 918,
      "notifications": [
        {
          "key": "okta-attendance.student_absent",
          "display_name_ar": "غياب طالب",
          "display_name_en": "Student absent",
          "audience": ["guardian"],
          "variables": { "student_name": "string", "date": "date" },
          "default_template": "غاب {{ student_name }} يوم {{ date }}",
          "default_channels": ["whatsapp", "in_app"],
          "tenant": { "enabled": true, "channels": ["whatsapp"], "template_id": "student_absent_v2", "has_custom_template": false }
        }
      ]
    }
  ]
}
  • key هو ما سيصلك في metadata.notification_key، و**variables** (اسم ← نوع) هي بالضبط أسماء ما يصل في variables مع كل إرسال — خانات قالبك تُبنى عليها.

  • tenant حالة الجهة للمفتاح إن ضبطته (null إن لم تضبطه بعد): هل هو مفعَّل، وعلى أي قنوات، وأيّ قالب من قوالبك اختارت. default_channels سقف المطوّر، وtenant.channels ما ضيّقته الجهة منه.

  • ?channel=whatsapp يقتصر على المفاتيح التي ستخرج فعلاً على هذه القناة هنا (اختيار الجهة إن وُجد، وإلا سقف المطوّر) — وهي القائمة التي تحتاج قالباً لكل بند فيها.

  • تطبيقات هذه الجهة وحدها، والمُعلَن النشط فقط؛ ولا مستلمين ولا حمولات ولا سجلّ تسليم لتطبيق غيرك.

  • installed_apps تسرد كل تثبيت نشط على الجهة بعدد إشعاراته (حتى مزوّدك نفسه بصفر) — فإن جاءت apps فارغة اقرأها: قائمة تثبيتات بلا إشعارات تعني أن التطبيقات لا تُعلن شيئاً؛ وقائمة فارغة تعني أن توكنك على جهة أخرى (لكل شريك سندبوكس لكل نوع جهة — مدرسة، حضانة… — وكلٌّ بتثبيتاته)، وtenant_id في الردّ هو ما تقارنه بالجهة التي تفتحها في المتصفّح.

  • التوكن على السندبوكس: التثبيت هناك لا يُصدر توكناً تلقائياً. من حساب مدير السندبوكس POST /api/store/modules/{slug}/installation-token/rotate يُصدر أول توكن للتثبيت (بنطاقاته المعتمدة) ويعيده مرة واحدة — أو استعمل النداء المدمج أدناه بلا توكن.

  • المزوّد المدمج يقرؤه بلا HTTP — الصنف نفسه من داخل كودك، وهو تحت App\Services\PartnerApi\* فالفاحص يسمح باستيراده:

    use App\Services\PartnerApi\Notifications\Catalog\GetInstalledAppsNotificationCatalog;
    
    // على صفحات إعداداتك (الوسيط بنى سياق تثبيتك) أو داخل send() على العامل
    // (تصل ملفوفاً بسياقك) — الجهة والتثبيت من السياق، لا من وسيط.
    $catalog = app(GetInstalledAppsNotificationCatalog::class)('whatsapp');
    
    foreach ($catalog['apps'] as $app) {
        foreach ($app['notifications'] as $n) {
            // $n['key'] هو ما سيصلك في metadata.notification_key،
            // و$n['variables'] أسماء خانات قالبك.
            $this->ensureTemplateFor($n['key'], array_keys($n['variables']));
        }
    }
    

    خارج أي سياق تثبيت (من tinker مثلاً) يُرفض بـno_app_context — لا يجيب عن جهة مجهولة.

الكتالوج يتغيّر حين تنشر التطبيقات إصدارات جديدة، فأعد قراءته دورياً أو عند أول مفتاح مجهول يصلك — لا تخبّئه إلى الأبد.

التحقق من التوقيع

$timestamp = $request->header('X-Okta-Timestamp');
$signature = $request->header('X-Okta-Signature');
$body      = $request->getContent();

$expected = hash_hmac('sha256', $timestamp . '.' . $body, $signingSecret);

if (! hash_equals($expected, $signature)) {
    abort(401, 'invalid_signature');
}

// رفض إن كان الطابع الزمني أقدم من 5 دقائق
if (abs(time() - (int) $timestamp) > 300) {
    abort(401, 'timestamp_skew');
}

الرد المتوقَّع

HTTP/1.1 200 OK
Content-Type: application/json

{ "provider_id": "wamid.HBgL..." }
  • 2xx = نجاح. أعِد provider_id لتربط الرسالة بمعرف المزوِّد.
  • 4xx = فشل دائم (رقم خاطئ، رسالة مرفوضة، ...). لا retry.
  • 5xx = فشل مؤقّت. okta-web تعيد المحاولة مرة واحدة.

الحصول على signingSecret

السر يصدر تلقائياً عند install على المنصة. للحصول عليه برمجياً:

// من جانب okta-partners
$creds = app(\App\Services\OktaWebService::class)
    ->getInstallationCredentials($installationId);

$signingSecret = $creds['signing_secret'] ?? null;

أو يدوياً عبر زر تدوير Token في صفحة install على okta-web — يعرض signing_secret مرة واحدة. كل تدوير يُنتج سراً جديداً.

مسار 2: delivery=embedded

تشحن class داخل okta-web يُطبِّق العقد:

namespace Modules\AcmeSms\Providers;

use App\Contracts\PartnerNotificationProvider;
use App\Services\PartnerApi\Notifications\NotificationPayload;
use App\Services\PartnerApi\Notifications\NotificationResult;

final class AcmeSmsProvider implements PartnerNotificationProvider
{
    public function send(NotificationPayload $payload): NotificationResult
    {
        try {
            // أيُّ إشعار هذا، ومن أرسله، وأيّ صفّ تسليم — راجع «هوية الإشعار».
            $key = $payload->metadata['notification_key'] ?? null;   // okta-attendance.student_absent
            $template = $payload->templateId                          // اختيار الجهة إن وُجد
                ?? $this->templateFor($key);                          // وإلا ما تربطه أنت بالمفتاح

            $response = SmsClient::send([
                'to'        => $payload->recipientIdentifier,
                'text'      => $payload->body,
                'template'  => $template,
                'params'    => $payload->variables,                   // متغيّرات الكتالوج تملأ الخانات
                'reference' => $payload->metadata['delivery_id'] ?? null,
            ]);

            return NotificationResult::success(providerId: $response['id'] ?? null);
        } catch (\Throwable $e) {
            return NotificationResult::failure($e->getMessage());
        }
    }

    public function supports(string $channel): bool
    {
        return $channel === 'sms';
    }

    public function isConfigured(): bool
    {
        return ! empty(config('acme-sms.api_key'));
    }
}

ملاحظات:

  • المسار لازم يبدأ بأحد الـ namespaces المسموح بها: Modules\ (الافتراضي) أو ما يضيفه فريق المنصة في partners.embedded_notification_namespaces.
  • لا تَرمِ exceptions من send() — التف بـ try/catch وأعِد NotificationResult::failure().

مسار 3: delivery=hybrid

يبني المنصة HybridNotificationProvider يجرّب المسار embedded أولاً، ويتراجع لـ api عند:

  • embedded.isConfigured() يُرجِع false.
  • embedded.send() يُرجِع NotificationResult::failure().

مفيد للـ canary releases: تشحن class جديد لإصدار، تترك الـ API كاحتياط لو الـ class تعطّل.

واجهة الإعدادات (settings_ui) — اختيارية

لو تطبيقك يحتاج صفحة إعدادات داخل لوحة tenant على okta-web، صرّح في manifest:

"settings_ui": {
  "has_settings_page": true,
  "livewire_component": "partner-apps.acme-sms.settings"
}

المنصة تعرض رابط "فتح الإعدادات" في صفحة /partner-apps/notification/providers ويفتح shell موحَّد يحمّل مكوِّن Livewire الخاص بك. المسار لازم يطابق livewire-component مُسجَّل (تشحنه ضمن نفس module).

الـ overrides لكل إصدار

كل الحقول السابقة (channels, delivery, api.send_endpoint, embedded.provider_class, capabilities, settings_ui) قابلة للتخصيص لكل إصدار من تبويب التكامل في صفحة الإصدار. الحقل الذي يُترك فارغاً يرث من الـ module تلقائياً عند build، فلا حاجة لتكرار الإعدادات في كل release.


عرض الدليل كاملاً في صفحة واحدة · ↧ Markdown خام