API Echo
واجهة REST API عامة وWebhooks: يمكن ربط اقتصاد الخادم ومستوياته وبيانات دليل الخوادم بخدماتك الخاصة — خادم ألعاب أو موقع أو بوت أو أتمتة.
https://api.echo-assistants.tech
التفويض
يحتاج كل طلب إلى مفتاح في الترويسة Authorization. المفتاح يخص الخادم وليس الحساب. لا ينشئه إلا مالك الخادم في لوحة التحكم ← «المطوّرون»: بحد أقصى 3 (10 مع Premium) مفاتيح. نقاط النهاية /catalog/* عامة ولا تحتاج إلى مفتاح (الحد 60 طلبًا في الدقيقة لكل عنوان IP، والردود مخزّنة مؤقتًا).
curl https://api.echo-assistants.tech/me \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
أمان المفاتيح
يبدأ المفتاح بـ echoapi_، يليه 30 رمزًا عشوائيًا من الحروف اللاتينية والأرقام، ثم مجموع تحقق من 6 رموز. ترفض API فورًا (401) أي سلسلة مجموع تحققها خاطئ. مفاتيح الصيغة القديمة (echo_…) تبقى صالحة، والمفتاح المعاد إصداره يحصل على الصيغة الجديدة.
\bechoapi_[0-9A-Za-z]{36}\bيُعطَّل المفتاح المكشوف علنًا تلقائيًا. عبر برنامج Secret Scanning يبلغنا GitHub بالمفاتيح الموجودة في المستودعات العامة، ويرصد بوت Echo المفاتيح في رسائل الخوادم ويحذف تلك الرسائل. يتلقى مالك الخادم ومنشئ المفتاح إشعارًا على الموقع وفي Telegram وبالبريد الإلكتروني، مع رابط إلى مكان العثور على المفتاح.
ما العمل: فتح مفاتيح الخادم في بوابة المطوّرين والضغط على إعادة الإصدار في بطاقة المفتاح. يحصل المفتاح على سرّ جديد ويعمل مجددًا، ويتوقف السابق فورًا. يجب استبدال المفتاح في كل مكان يُحفظ فيه. لا يمكن تفعيل مفتاح عُثر عليه دون إعادة إصداره. إذا لم يعد المفتاح لازمًا فيمكن إبطاله.
يُحفظ المفتاح على خادمك: في متغيرات البيئة أو مخزن الأسرار. لا يجوز وضعه في شيفرة الصفحات أو التطبيقات أو في المستودعات أو الرسائل أو لقطات الشاشة.
الخدمة
/meبدون نطاقالتحقق من المفتاح: يعيد الخادم ونطاقاته.
curl "https://api.echo-assistants.tech/me" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "guildId": "1369...", "scopes": ["levels:read"] }الاقتصاد
/economy/balance?user=<id>economy:readرصيد العضو: المحفظة والبنك والرقائق.
curl "https://api.echo-assistants.tech/economy/balance?user=123456789012345678" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "user": "123...", "balance": 1500, "bank": 200, "chips": 0 }/economy/balanceeconomy:writeإضافة العملة أو خصمها. يمكن أن تكون قيمة delta سالبة، ويبقى الرصيد ضمن 0…2,000,000,000.
curl -X POST "https://api.echo-assistants.tech/economy/balance" \
-H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "user": "123...", "delta": 250 }'{ "user": "123...", "delta": 250 }{ "ok": true, "user": "123...", "balance": 1750 }/economy/leaderboard?limit=<n>economy:readالأعضاء الأعلى رصيدًا (limit من 1 إلى 100، والافتراضي 20).
curl "https://api.echo-assistants.tech/economy/leaderboard?limit=20" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "guildId": "1369...", "leaderboard": [ { "rank": 1, "userId": "627...", "balance": 15000, "bank": 3000 } ] }المستويات
/levels/leaderboard?limit=<n>levels:readالأعضاء الأعلى في نقاط الخبرة (limit من 1 إلى 100، والافتراضي 20).
curl "https://api.echo-assistants.tech/levels/leaderboard?limit=20" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "guildId": "1369...", "leaderboard": [ { "rank": 1, "userId": "627...", "xp": 3965, "messages": 374, "voiceSeconds": 1273 } ] }/levels/xp?user=<id>levels:readنقاط خبرة العضو ومستواه، وnextLevelXp هو إجمالي نقاط الخبرة اللازمة للمستوى التالي.
curl "https://api.echo-assistants.tech/levels/xp?user=123456789012345678" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "user": "123...", "xp": 1320, "level": 4, "nextLevelXp": 1500 }/levels/xplevels:writeإضافة نقاط الخبرة أو خصمها: delta عدد صحيح، حتى ±1,000,000 لكل طلب، ولا تنزل نقاط الخبرة تحت الصفر. يُعاد حساب المستوى فورًا. إذا ارتفع المستوى، يمنح البوت مكافآته (الرتب والعملات والإعلان) كما في الترقية العادية، ويرسل الحدث member.level_up مع العلامة "via": "api". خفض المستوى لا يسحب المكافآت. لأعضاء الخادم فقط (وإلا 404 not_member)، وإذا كانت المستويات معطّلة في الخادم: 409 levels_off.
curl -X POST "https://api.echo-assistants.tech/levels/xp" \
-H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "user": "123...", "delta": 250 }'{ "user": "123...", "delta": 250 }{ "ok": true, "user": "123...", "xp": 1570, "level": 5, "previousXp": 1320, "previousLevel": 4, "levelUp": true }دليل الخوادم
/monitoring/servermonitoring:readبطاقة الخادم في الدليل: اللهب خلال الفترة والإجمالي والمركز في الترتيب والحالات.
curl "https://api.echo-assistants.tech/monitoring/server" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "guildId": "1369...", "slug": "my-server", "flames": 420, "flamesTotal": 9100, "published": true, "rank": 3, "partner": true, "verified": true }/monitoring/top?limit=<n>monitoring:readخوادم الدليل الأعلى في اللهب (limit من 1 إلى 100، والافتراضي 20).
curl "https://api.echo-assistants.tech/monitoring/top?limit=20" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "top": [ { "rank": 1, "guildId": "1369...", "slug": "my-server", "flames": 980, "flamesTotal": 21000 } ] }التكاملات
ربط لعبتك أو موقعك أو نظام CRM: تتحول سمات الأعضاء إلى رتب وفق القواعد في لوحة التحكم ← «التكاملات»، ويربط اللاعبون حساباتهم برمز، ويمكن منح الرتب المسموح بها مباشرة. أجسام طلبات الكتابة بصيغة JSON بحجم حتى 8 كيلوبايت.
/members/<discordId>/attributesmembers:writeإرسال سمات العضو (استبدال كامل): حتى 25 حقلًا، والأسماء بحروف لاتينية وأرقام و_، والقيمة نص حتى 100 حرف أو رقم أو true/false. يعيد البوت حساب رتب القواعد فورًا.
curl -X PUT "https://api.echo-assistants.tech/members/123456789012345678/attributes" \
-H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "attributes": { "faction": "police", "rank": 3, "vip": true } }'{ "attributes": { "faction": "police", "rank": 3, "vip": true } }{ "ok": true, "discordId": "123...", "attributes": { "faction": "police", "rank": 3, "vip": true } }/members/<discordId>/attributesmembers:readما أُرسل عن العضو وأي حساب خارجي ربطه.
timedRoles: الرتب المسموح بها لـ API التي مُنحت لمدة محددة، ووقت سحب البوت لها.
curl "https://api.echo-assistants.tech/members/123456789012345678/attributes" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "discordId": "123...", "externalId": "Ivan_Petrov", "attributes": { "faction": "police", "rank": 3 }, "timedRoles": [ { "roleId": "1369...", "expiresAt": "2026-10-10T12:00:00.000Z" } ] }/members/<discordId>/attributesmembers:writeمسح كل سمات العضو. يسحب البوت الرتب التي منحتها القواعد، ولا يمس الرتب الممنوحة يدويًا.
curl -X DELETE "https://api.echo-assistants.tech/members/123456789012345678/attributes" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "ok": true, "removed": true }/external/<externalId>/attributesmembers:writeالأمر نفسه حسب المعرّف في نظامك (الاسم المستعار، UUID، license:…). يسري على من ربط الحساب برمز، وقبل ذلك يُحفظ ويُطبَّق عند الربط. عند تطابق الأسماء تكون الأولوية للسمات المرسلة حسب معرّف Discord.
curl -X PUT "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \
-H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "attributes": { "faction": "army", "rank": 5 } }'{ "attributes": { "faction": "army", "rank": 5 } }{ "ok": true, "externalId": "Ivan_Petrov", "discordId": "123...", "attributes": { "faction": "army", "rank": 5 } }/external/<externalId>/attributesmembers:readما أُرسل عن الحساب الخارجي ومن ربطه (discordId: null تعني أن أحدًا لم يربطه بعد).
curl "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "externalId": "Ivan_Petrov", "discordId": "123...", "attributes": { "faction": "army", "rank": 5 } }/external/<externalId>/attributesmembers:writeمسح كل سمات الحساب الخارجي.
curl -X DELETE "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "ok": true, "removed": true }/link-codeslinks:writeرمز ربط للاعب: 8 أحرف، للاستخدام مرة واحدة، وصالح لمدة ttlMinutes (من 1 إلى 60، والافتراضي 15). يضغط اللاعب «ربط الحساب» في لوحة الخادم (أو يستخدم /link) ويُدخل الرمز. الرمز الجديد للحساب نفسه يُبطل الرمز السابق.
curl -X POST "https://api.echo-assistants.tech/link-codes" \
-H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "externalId": "Ivan_Petrov", "ttlMinutes": 15 }'{ "externalId": "Ivan_Petrov", "ttlMinutes": 15 }{ "code": "K7PQ3MZA", "display": "K7PQ-3MZA", "externalId": "Ivan_Petrov", "expiresAt": "2026-09-29T12:15:00.000Z" }/links?externalId=<externalId>links:readصاحب الحساب: حسب externalId أو حسب discordId (?discordId=…). إذا لم يوجد ربط: 404 not_linked.
curl "https://api.echo-assistants.tech/links?externalId=Ivan_Petrov" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "externalId": "Ivan_Petrov", "discordId": "123...", "linkedAt": "2026-09-29T12:03:11.000Z" }/links?externalId=<externalId>links:writeفك ربط الحساب (أو ?discordId=…). تتوقف سمات الحساب الخارجي عن التأثير في الشخص.
curl -X DELETE "https://api.echo-assistants.tech/links?externalId=Ivan_Petrov" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "ok": true, "externalId": "Ivan_Petrov", "discordId": "123..." }/members/<discordId>/rolesroles:writeمنح الرتب وسحبها مباشرة، وذلك للرتب التي حددها المالك في بوابة المطوّرين ← «مفاتيح API» ← «مسموح للمفاتيح» فقط (حتى 10 في كل قائمة). يسحب remove أي رتبة مسموح بها، حتى الممنوحة يدويًا. ينفّذ البوت ذلك: الرد 202 مع رقم المهمة.
durationMinutes (من 1 إلى 525,600): منح الرتب من add لمدة محددة، وعند انتهائها يسحبها البوت تلقائيًا. لا تُقصِّر المدة الجديدة مدة محددة مسبقًا، والرتبة التي كانت لدى العضو بلا مدة تبقى دائمة. add بدون durationMinutes يجعل الرتبة دائمة، وremove يلغي السحب المجدول.
curl -X POST "https://api.echo-assistants.tech/members/123456789012345678/roles" \
-H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "add": ["1369..."], "remove": ["1370..."], "durationMinutes": 1440 }'{ "add": ["1369..."], "remove": ["1370..."], "durationMinutes": 1440 }{ "ok": true, "opId": 42, "status": "pending" }/role-ops/<opId>roles:writeنتيجة مهمة المنح: pending أو done أو failed، وما حدث مع كل رتبة.
في المنح المؤقت يوضح result.expiresAt متى سيسحب البوت كل رتبة ممنوحة (null تعني أن الرتبة كانت لدى العضو بلا مدة وبقيت دائمة).
curl "https://api.echo-assistants.tech/role-ops/42" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "opId": 42, "discordId": "123...", "status": "done", "durationMinutes": 1440, "result": { "added": ["1369..."], "removed": [], "failed": [], "expiresAt": { "1369...": "2026-10-10T12:00:00.000Z" } } }/channels/<channelId>/messagesmessages:writeرسالة باسم البوت (البوت الخاص بالخادم إن كان مربوطًا)، وذلك فقط إلى القنوات التي حددها المالك في بوابة المطوّرين ← «مفاتيح API» ← «مسموح للمفاتيح» (حتى 10). content حتى 2000 حرف، وembeds حتى 10 عناصر Embed بسيطة: title وdescription وurl وcolor وtimestamp وauthor.name وfooter.text وimage.url وthumbnail.url وfields، والروابط https فقط. الإشارات لا تنبّه أحدًا افتراضيًا: يمكن تنبيه أعضاء الخادم المدرجين في mentionUsers فقط (حتى 10)، أما @everyone و@here والرتب فلا تُنبَّه أبدًا. جسم الطلب حتى 32 كيلوبايت. ينفّذ البوت ذلك: الرد 202 مع رقم المهمة.
curl -X POST "https://api.echo-assistants.tech/channels/137100000000000000/messages" \
-H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "content": "Сервер перезапустится через 5 минут", "embeds": [ { "title": "Технические работы", "description": "Начало в 03:00 МСК", "color": 9133302 } ], "mentionUsers": ["123..."] }'{ "content": "Сервер перезапустится через 5 минут", "embeds": [ { "title": "Технические работы", "description": "Начало в 03:00 МСК", "color": 9133302 } ], "mentionUsers": ["123..."] }{ "ok": true, "opId": 77, "status": "pending" }/message-ops/<opId>messages:writeنتيجة الإرسال: pending، أو done (معرّف الرسالة ورابطها)، أو failed مع السبب: channel_not_allowed أو channel_missing أو missing_permissions أو rejected.
curl "https://api.echo-assistants.tech/message-ops/42" \ -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
{ "opId": 77, "channelId": "1371...", "status": "done", "result": { "messageId": "1372...", "channelId": "1371...", "url": "https://discord.com/channels/1369.../1371.../1372..." } }الدليل العام
/catalog/servers?search=&tag=&lang=&limit=عام · بدون مفتاحالبحث في خوادم دليل الخوادم وعرض قائمتها. عامة بالكامل ولا تحتاج إلى مفتاح.
curl "https://api.echo-assistants.tech/catalog/servers"
{ "count": 1, "servers": [ { "slug": "my-server", "name": "My Server", "memberCount": 5200, "flames": 980, "tags": ["Игры"], "language": "ru", "invite": "https://discord.gg/...", "badges": { "verified": true } } ] }/catalog/top?limit=<n>عام · بدون مفتاحترتيب الدليل حسب اللهب.
curl "https://api.echo-assistants.tech/catalog/top?limit=20"
{ "top": [ { "rank": 1, "slug": "my-server", "name": "My Server", "flames": 980 } ] }/catalog/server/<slug>عام · بدون مفتاحبطاقة الخادم العامة: الوصف واللهب والمركز والوسوم والشارات والدعوة والتقييم.
curl "https://api.echo-assistants.tech/catalog/server/my-server"
{ "slug": "my-server", "name": "My Server", "flames": 980, "rank": 3, "rating": { "avg": 8.7, "count": 42 }, "invite": "https://discord.gg/..." }/catalog/reviews/<slug>?limit=<n>عام · بدون مفتاحمراجعات الخادم مع متوسط التقييم.
curl "https://api.echo-assistants.tech/catalog/reviews/my-server?limit=20"
{ "slug": "my-server", "summary": { "avg": 8.7, "count": 42 }, "reviews": [ { "user": "Ник", "rating": 9, "comment": "Топ", "at": "..." } ] }/catalog/tagsعام · بدون مفتاحوسوم الدليل وفئاته مع عدد الخوادم في كل منها.
curl "https://api.echo-assistants.tech/catalog/tags"
{ "tags": [ { "tag": "Игры", "count": 128 } ] }Webhooks
يرسل Echo الأحداث إلى عنوان URL الخاص بك في الوقت الفعلي. يُضبط Webhook في لوحة التحكم ← «المطوّرون». كل طلب موقّع بالترويسة X-Echo-Signature (HMAC-SHA256 لجسم الطلب باستخدام المفتاح السري)، واسم الحدث في X-Echo-Event.
flame.bumpحصل الخادم على اللهب، في الدليل أو عبر الأمر /flame.
{
"event": "flame.bump",
"guildId": "1369...",
"flames": 421,
"flamesTotal": 9101,
"amount": 1,
"source": "web",
"at": "2026-07-27T20:30:00.000Z"
}testيُرسَل بالزر «إرسال اختبار» للتحقق من أن المستقبِل يعمل.
{ "event": "test", "guildId": "1369...", "message": "Проверка вебхука Echo", "at": "..." }import crypto from "node:crypto";
// body هو النص الخام للطلب، وsecret من لوحة التحكم
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(body).digest("hex");
const header = req.headers["x-echo-signature"];
const got = Buffer.from(typeof header === "string" ? header : "");
const want = Buffer.from(expected);
const ok = got.length === want.length && crypto.timingSafeEqual(got, want);إشعارات التكاملات (Premium)
يُضبط في لوحة التحكم ← «التكاملات». يرسل Echo طلب POST بصيغة JSON {"id", "event", "guildId", "at", "data"}: التوقيع في X-Echo-Signature (sha256= ثم HMAC-SHA256 للجسم الخام باستخدام المفتاح السري)، واسم الحدث في X-Echo-Event، ورقم التسليم في X-Echo-Delivery.
يجب الرد بـ 2xx خلال 8 ثوانٍ. وإلا نعيد المحاولة بعد 30 ثانية ودقيقتين و10 دقائق وساعة بالمعرّف id نفسه، ولا تُتَّبع عمليات إعادة التوجيه. يجب أن يكون العنوان https على المنفذ 443 وبعنوان IP عام. بعد 20 إخفاقًا متتاليًا يتوقف الإرسال، ويمكن إعادة تفعيله في لوحة التحكم بعد إصلاح المستقبِل.
member.joinانضم عضو إلى الخادم.
{ "id": "evt_…", "event": "member.join", "guildId": "1369...", "at": "2026-09-29T12:00:00.000Z",
"data": { "discordId": "123...", "username": "ivan", "joinedAt": "2026-09-29T12:00:00.000Z" } }member.leaveغادر عضو الخادم أو أُزيل منه.
{ "id": "evt_…", "event": "member.leave", "guildId": "1369...", "at": "…", "data": { "discordId": "123...", "username": "ivan" } }member.level_upارتفع مستوى عضو.
{ "id": "evt_…", "event": "member.level_up", "guildId": "1369...", "at": "…", "data": { "discordId": "123...", "oldLevel": 4, "newLevel": 5, "xp": 1320 } }store.purchaseشراء من متجر الخادم: منح رتبة، أو إضافة حزمة عملات (kind: coins، والحقل coins)، أو دعم الخادم. لا يوجد مشترٍ في الدعم المجهول.
{ "id": "evt_…", "event": "store.purchase", "guildId": "1369...", "at": "…",
"data": { "orderId": 815, "kind": "role", "itemName": "VIP", "roleId": "1369...", "buyerId": "123...", "discordId": "123...", "priceKopecks": 14900, "durationDays": 30 } }ticket.openedفُتحت تذكرة.
{ "id": "evt_…", "event": "ticket.opened", "guildId": "1369...", "at": "…", "data": { "ticketId": 311, "number": 57, "discordId": "123...", "channelId": "1371...", "panelId": 4 } }account.linkedربط لاعب حسابه برمز.
{ "id": "evt_…", "event": "account.linked", "guildId": "1369...", "at": "…",
"data": { "externalId": "Ivan_Petrov", "discordId": "123...", "username": "ivan", "previousDiscordId": null, "previousExternalId": null } }testالزر «إرسال اختبار» في لوحة التحكم ← «التكاملات».
{ "id": "evt_…", "event": "test", "guildId": "1369...", "at": "…", "data": { "message": "…" } }import crypto from "node:crypto";
import express from "express";
import pg from "pg";
const app = express();
const SECRET = process.env.ECHO_WEBHOOK_SECRET; // whsec_… из панели
const GUILD_ID = process.env.ECHO_GUILD_ID;
if (!SECRET || !GUILD_ID) throw new Error("ECHO_WEBHOOK_SECRET and ECHO_GUILD_ID required");
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Выполните миграцию в своей PostgreSQL заранее:
// CREATE TABLE echo_webhook_events (
// guild_id text NOT NULL, event_id text NOT NULL,
// PRIMARY KEY (guild_id, event_id)
// );
// Храните ID минимум 2 часа. Несколько процессов используют одну таблицу.
// يُحسب التوقيع على الجسم الخام قبل تحليل JSON
app.post("/echo-webhook", express.raw({ type: "application/json", limit: "64kb" }), async (req, res) => {
if (!Buffer.isBuffer(req.body)) return res.status(400).end();
const expected = "sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const got = Buffer.from(req.get("X-Echo-Signature") ?? "");
const want = Buffer.from(expected);
const ok = got.length === want.length && crypto.timingSafeEqual(got, want);
if (!ok) return res.status(401).end();
let event;
try { event = JSON.parse(req.body.toString("utf8")); }
catch { return res.status(400).end(); }
if (!event || typeof event !== "object" || Array.isArray(event) ||
typeof event.id !== "string" || !/^evt_[0-9a-f-]{36}$/.test(event.id) ||
event.guildId !== GUILD_ID || typeof event.event !== "string" ||
typeof event.at !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(event.at)) return res.status(400).end();
const at = Date.parse(event.at);
const age = Date.now() - at;
// Повторы сохраняют at; неверная дата и будущее дальше 5 минут — отказ.
if (!Number.isFinite(at) || new Date(at).toISOString() !== event.at || age > 2 * 60 * 60_000 || age < -5 * 60_000) return res.status(400).end();
let db;
try {
db = await pool.connect();
await db.query("BEGIN");
const claimed = await db.query(
"INSERT INTO echo_webhook_events (guild_id, event_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING event_id",
[event.guildId, event.id],
);
if (!claimed.rowCount) {
await db.query("ROLLBACK");
return res.status(204).end();
}
// Изменение награды/баланса делайте через db В ЭТОЙ транзакции.
// Для внешнего действия записывайте outbox здесь и обрабатывайте отдельно.
await db.query("COMMIT");
return res.status(204).end();
} catch {
if (db) await db.query("ROLLBACK").catch(() => {});
return res.status(500).end();
} finally {
db?.release();
}
});<?php
$secret = getenv('ECHO_WEBHOOK_SECRET'); // whsec_… из панели
$guildId = getenv('ECHO_GUILD_ID');
if (!$secret || !$guildId) { http_response_code(500); exit; }
// يُحسب التوقيع على الجسم الخام قبل تحليل JSON
$body = file_get_contents('php://input');
if ($body === false || strlen($body) > 65536) { http_response_code(413); exit; }
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
$got = $_SERVER['HTTP_X_ECHO_SIGNATURE'] ?? '';
if (!hash_equals($expected, $got)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
if (!is_array($event) || !is_string($event['id'] ?? null) ||
!preg_match('/^evt_[0-9a-f-]{36}$/D', $event['id']) ||
($event['guildId'] ?? null) !== $guildId ||
!is_string($event['event'] ?? null) || !is_string($event['at'] ?? null) ||
!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/D', $event['at'])) {
http_response_code(400);
exit;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.v\Z', $event['at'], new DateTimeZone('UTC'));
$at = $date && $date->format('Y-m-d\TH:i:s.v\Z') === $event['at'] ? $date->getTimestamp() : false;
$age = $at === false ? null : time() - $at;
if ($age === null || $age > 7200 || $age < -300) {
http_response_code(400); exit;
}
// Та же миграция PostgreSQL, что в Node.js-примере.
// ECHO_DATABASE_DSN: pgsql:host=…;dbname=… (PDO_PGSQL).
$db = new PDO(getenv('ECHO_DATABASE_DSN'), getenv('ECHO_DATABASE_USER'), getenv('ECHO_DATABASE_PASSWORD'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
try {
$db->beginTransaction();
$claim = $db->prepare('INSERT INTO echo_webhook_events (guild_id, event_id) VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING event_id');
$claim->execute([$event['guildId'], $event['id']]);
if ($claim->fetchColumn() === false) {
$db->rollBack(); http_response_code(204); exit;
}
// Изменение награды/баланса делайте через $db В ЭТОЙ транзакции.
// Внешние действия — через outbox, записанный здесь.
$db->commit();
http_response_code(204);
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
http_response_code(500);
}النطاقات
عند إنشاء المفتاح تُحدَّد صلاحياته، ولكل نقطة نهاية نطاق مطلوب خاص بها.
guild:readeconomy:readeconomy:writelevels:readmonitoring:readmembers:readmembers:writeroles:writelinks:readlinks:writelevels:writemessages:writeالحدود والأخطاء
بحد أقصى 3 (10 مع Premium) من المفاتيح لكل خادم، والحد 40 (120 مع Premium) من الطلبات في الدقيقة لكل مفتاح. تُعاد الأخطاء بصيغة JSON { "error": "..." } مع رمز HTTP المناسب.
| الرمز | error | متى |
|---|---|---|
| 400 | bad_fields | معاملات الطلب غير صحيحة أو ناقصة. |
| 401 | unauthorized | لم يُرسَل المفتاح، أو أنه غير صحيح أو مُبطَل. |
| 403 | insufficient_scope | ليس لدى المفتاح النطاق المطلوب لهذا الطلب. |
| 403 | key_paused_premium | المفتاح متوقف مؤقتًا: لا يوجد Premium للخادم، وبدونه تعمل فقط المفاتيح الأقدم إصدارًا ضمن الحد المجاني. |
| 404 | not_linked | الحساب غير مربوط (طرق الربط) أو لم يُعثر على المهمة. |
| 409 | levels_off | الإجراء لا يتوافق مع إعدادات الخادم: مثلًا، المستويات معطّلة. |
| 413 | body_too_large | جسم الطلب أكبر من 8 كيلوبايت. |
| 429 | rate_limited | تم تجاوز حد الطلبات في الدقيقة (40 (120 مع Premium)). |
| 502 | discord_unavailable | لم يستجب Discord. يُرجى إعادة المحاولة لاحقًا. |
يظهر المفتاح كاملًا مرة واحدة فقط عند إنشائه، لذا يجب حفظه. قد تعطّل إدارة المشروع المفتاح عند إساءة استخدامه.