بناء التطبيق
تطبيقات الإشعار (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). - لا يُسمح بـ
notificationblock إلا حين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.