Skip to main content
القناة (channel) تربط الوكيل بواجهة واحدة: واجهة JSON برمجية، أو webhook، أو تطبيق محادثة. وهي تحدد كيف يُتحقق من هوية الطلب الوارد، وإلى أي محادثة ينتمي، وكيف يعود رد الوكيل (أو الموافقة التي توقّف عندها) إلى تلك الواجهة. تقدّم mountChannels() قنواتك كلها خلف معالج واحد من الشكل (req, res)، وتعالج كل طلب بالطريقة نفسها:
القنوات ميزة جديدة. وما زالت محوّلات المُشغِّلات تعمل: صار WebhookTriggerAdapter يستخدم webhookChannel() في المصادقة والتحليل. وتضيف القنوات ما تفتقر إليه المُشغِّلات: كل محادثة على الواجهة هي جلسة، والموافقات والأسئلة تعود إلى الواجهة نفسها.

البدء السريع

httpChannel() هي القناة المرجعية: تستقبل { sessionKey, input } وتُخرج JSON.
يُرجع المعالج true إذا خدم الطلب، وfalse (دون أن يكتب شيئًا) لأي مسار آخر، مثل مسارات /chat في lousho dev، فيمكنك تركيبه إلى جانب مساراتك الخاصة.

العقد

defineChannel({ ... }) تتحقق من الاسم وتُرجع التعريف: يتيح ctx (ChannelContext) لـ parse أن يسأل المضيف بدل الاحتفاظ بحالة: ctx.approval(id) (الاستدعاء المعلّق)، وctx.sessionId(key) وctx.hasSession(key) (أي أن جلسة لهذا المفتاح محفوظة في store). يمكن أن يحمل { decision } الحقل inbound (المحادثة كما تسمّيها النقرة نفسها، فيبقى التوقف المؤقت قائمًا بعد إعادة التشغيل) والحقل approver (مَن اتخذ القرار؛ يُمرَّر إلى mountChannels({ onDecision }) لسجل التدقيق لديك). req هو ChannelRequest مستقل عن أي إطار عمل: method وurl وheaders (بأسماء بأحرف صغيرة) وrawBody (البايتات كما وصلت: تحقق من التواقيع عليها هي، لا على JSON أُعيدت سَلسَلته) وtext وnative (طلب المضيف الأصلي). الرد الذي لا يستدعي respond يتبعه 200 {"ok":true}؛ فالواجهة التي تسلّم الردود خارج الطلب (بالنشر عبر واجهة برمجية لتطبيق محادثة) يكفيها الإقرار باستلام الطلب. والخطأ الذي يرميه parse يُردّ عليه بـ 400 إن كان SyntaxError (JSON غير صالح) وبـ 500 فيما عدا ذلك؛ والجسم الذي يتجاوز 1MB يُردّ عليه بـ 413.

الجلسات

الرسائل التي تحمل sessionKey نفسه على القناة نفسها تتشارك جلسة واحدة، فيرى الوكيل ما سبق من تبادلات؛ ودورات الجلسة الواحدة تُنفَّذ واحدة تلو الأخرى. معرّفات الجلسات لا تقبل إلا A-Za-z0-9_-، فالمعرّف الذي يحوي محارف أخرى (والافتراضي `${name}:${sessionKey}` يحوي : دائمًا) تُستبدل بها _ ويُلحق به تجزئة (hash) للمعرّف الأصلي: sms:+1555 يصبح sms__1555-<hash>. أرجِع معرّفًا صالحًا من sessionId() ليُستخدم كما هو. تُحفظ سجلات المحادثات في mountChannels(agent, channels, { store }): وهو SessionStore أو { sessions, checkpoints } مثل SqliteStore (مرّر المخزن نفسه الذي أعطيته لـ createAgent({ store })). ومن دون store يحتفظ بها المعالج في الذاكرة.

الموافقات والأسئلة

حين تتوقف دورة مؤقتًا عند أداة تتطلب موافقة، أو عند استدعاء ask_question، يستدعي المعالج onApproval (والافتراضي هو reply مع نص طلب مثل Approve send_email {"to":"sam@example.com"}? (approval id: ...)). ويُتخذ القرار بإحدى طريقتين؛ وتعود متابعة التشغيل عبر reply الخاص بالقناة نفسها (أو onApproval من جديد إن توقفت مرة أخرى):
  • handler.resolveApproval({ id, approved, note? }) أو handler.resolveApproval({ id, answer }) من شيفرتك، مثلًا من دالة رد النداء لزر في الواجهة.
  • POST <basePath>/<name>/approvals/<id> مع { approved, note? } أو { answer }. يُنفَّذ verify الخاص بالقناة أولًا؛ والمعرّف الذي لم تتوقف عنده القناة يُردّ عليه بـ 404.

القنوات المضمّنة

webhookChannel({ secret }) تتحقق من توقيع HMAC-SHA256 للجسم الخام في x-signature-256: sha256=<hex>؛ ويقبل auth أي مصادقة webhook (خيارات HMAC مع الحماية من إعادة الإرسال، أو رمز bearer، أو مصادقة مخصصة). والفحوص والرد 401 العام هي نفسها التي يستخدمها WebhookTriggerAdapter.

في مجلد الوكيل

يمكن لـ مجلد الوكيل أن يضع قنواته في channels/*.ts، قناة واحدة مُصدَّرة افتراضيًا (default export) في كل ملف (وتُسمّى باسم الملف ما لم تحدد القناة اسمًا). تُرجعها resolveAgentDir() في channels، ويركّبها خادم node عبر createDeployedServer(agent, { channels }).

من يحق له الموافقة (Slack وDiscord)

تقبل القناتان الخيار approvers: قائمة بمعرّفات المستخدمين على المنصة، أو دالة (user: { id, name?, roles? }, { toolName, input, sessionId }) => boolean | Promise<boolean> (يملأ Discord الحقل roles بمعرّفات أدوار العضو). نقرة أي شخص آخر لا تحسم الموافقة: تصله رسالة مؤقتة لا يراها غيره تفيد بأنه “غير مسموح له”، وتبقى الموافقة معلّقة. ملاحظة أمنية. من دون approvers، لا يحق الموافقة إلا للمستخدم الذي بدأ الدورة (كاتب الرسالة على Slack، ومستخدم الأمر على Discord). في الإصدارات السابقة كان ذلك متاحًا لكل من يرى الرسالة؛ والقيمة approvers: () => true تعيد ذلك السلوك، ولا ينبغي أن تفعل ذلك إلا في قناة خاصة. وapprovers بصيغة الدالة يرفض احتياطًا (fail closed) حين لا تعرف العملية الاستدعاء المعلّق (بعد إعادة التشغيل)؛ فاستخدم صيغة القائمة أو السلوك الافتراضي للموافقات التي يجب أن تبقى صالحة بعدها. أما إجابات ask_question فغير مقيَّدة: الرسالة التالية (Slack) أو أمر /ask التالي (Discord) في المحادثة هو الإجابة. وهوية من اتخذ القرار تُمرَّر إلى mountChannels(agent, channels, { onDecision({ approver, decision, sessionId, channel }) {} }). الإخفاقات التي تقع بعد الإقرار بالاستلام تذهب إلى onError (خيار في القناة، أو في mountChannels)؛ انظر العقد أعلاه.

Slack

slackChannel({ signingSecret, botToken, name?, fetch? }) تربط تطبيق Slack. كل سلسلة رسائل (thread) في Slack هي جلسة واحدة: الإشارة إلى البوت تبدأ جلسة سلسلتها (أو تتابعها)، ومفتاحها الفريق والقناة وقيمة ts لرسالة السلسلة الأولى؛ والرسائل اللاحقة في سلسلة لها جلسة من قبل تتابعها دون إشارة (يُسأل مخزن الجلسات عن ذلك، فيبقى هذا السلوك قائمًا بعد إعادة التشغيل). والرسالة المباشرة إلى البوت تبدأ جلسة مفتاحها قناة الرسائل المباشرة. تُنشر الردود في السلسلة عبر chat.postMessage (بـ fetch وحدها، دون Slack SDK؛ مرّر fetch لحقن بديل عنها في الاختبارات).
  • يُتحقق من توقيع كل طلب (HMAC-SHA256 بصيغة v0 عبر Web Crypto، مع نافذة حماية من إعادة الإرسال مدتها خمس دقائق)؛ والإخفاق يُردّ عليه بـ 401.
  • يُردّ على الطلب فورًا (200، أو تحدي url_verification) وتُنفَّذ الدورة بعد الاستجابة، ضمن حد الثواني الثلاث الذي يفرضه Slack.
  • إعادات المحاولة (X-Slack-Retry-Num)، ورسائل البوتات (ومنها رسائل البوت نفسه)، ونسخة message المكررة من الإشارة: يُقرّ باستلامها كلها وتُتجاوز، فلا تُنفَّذ أي دورة مرتين.
  • تُنشر موافقة الأداة في السلسلة مع زرَّي Approve وDeny؛ والنقرة تستأنف الجلسة وتُنشر متابعة التشغيل في السلسلة. تُستبدل بالرسالة التي نُقر زرها النتيجةُ (“Approved by @user”)، فتختفي أزرارها. وتحمل النقرة قناتها وسلسلتها والمستخدم الذي يحق له القرار، فتعمل بعد إعادة التشغيل. أما ask_question فيُنشر نصًا، والرسالة التالية في السلسلة هي الإجابة.
أعدّ التطبيق في api.slack.com/apps:
  1. OAuth & Permissions: نطاقات رمز البوت app_mentions:read وchat:write، وchannels:history (مع groups:history للقنوات الخاصة) لرسائل المتابعة دون إشارة، وim:history للرسائل المباشرة. ثبّت التطبيق وانسخ رمز البوت (xoxb-...).
  2. Event Subscriptions: عنوان الطلب (Request URL) هو https://<host>/channels/slack؛ وأحداث البوت app_mention وmessage.channels (وmessage.groups للقنوات الخاصة)، وmessage.im للرسائل المباشرة (وفعّل أيضًا Messages Tab في App Home ليتمكن المستخدمون من مراسلة البوت).
  3. Interactivity & Shortcuts: عنوان الطلب (Request URL) هو https://<host>/channels/slack (المسار نفسه).
  4. Basic Information: انسخ سر التوقيع (signing secret).
معرفة السلاسل النشطة مصدرها store الذي تمرّره إلى mountChannels(). والموافقات المعلّقة تُحسم من النقرة نفسها. ولا يعيش في ذاكرة المعالج إلا ask_question المعلّق (أي: أيُّ سلسلة تنتظر إجابة): إعادة التشغيل تُنسيه إياه، فتكون الرسالة التالية دورة جديدة. وبعد إعادة التشغيل تُنشر متابعة التشغيل، لكنها لا تُضاف إلى سجل محادثة الجلسة، لأن نسخة الوكيل الجديدة ليست لديها جلسة مرتبطة بالموافقة.

Discord

discordChannel({ publicKey, applicationId, botToken?, name?, fetch? }) تربط تطبيق Discord عبر نقطة HTTP Interactions (دون اتصال gateway ودون مكتبة Discord؛ Web Crypto وfetch فقط، فتعمل أيضًا على Workers). الأمر المتوقع هو /ask prompt:<text>: خيار نصي واحد، والخيار prompt (أو أول خيار نصي) هو الرسالة.
  • يُتحقق في كل طلب من X-Signature-Ed25519 / X-Signature-Timestamp على timestamp + body؛ والتوقيع المفقود أو الخاطئ يُردّ عليه بـ 401، كما يشترط Discord. ويُردّ على PING بـ PONG.
  • يُقرّ باستلام الأمر فورًا باستجابة مؤجَّلة (ضمن حد الثواني الثلاث الذي يفرضه Discord)؛ ثم يعدّل الردُّ الاستجابةَ الأصلية (PATCH /webhooks/{applicationId}/{token}/messages/@original). والنص الذي يتجاوز 2000 محرف يُكمَل في رسائل متابعة.
  • الأوامر في القناة نفسها تتشارك جلسة واحدة، مفتاحها الخادم (guild) والقناة (والسلسلة، إن كانت في سلسلة).
  • تُنشر موافقة الأداة مع زرَّي Approve وDeny (ولا يحق النقر عليهما إلا لـ approvers، وهو افتراضيًا مستخدم الأمر)؛ والنقرة تستأنف الجلسة وتأتي متابعة التشغيل في رسالة متابعة. وتحمل النقرة المحادثة، فتعمل بعد إعادة التشغيل. أما ask_question فيُنشر نصًا، وأمر /ask التالي في تلك القناة هو الإجابة.
  • رموز التفاعل (interaction tokens) تبقى صالحة 15 دقيقة، فالرد (أو النقرة) بعد ذلك يفشل. وbotToken محجوز لاستدعاءات REST الخاصة بالبوت؛ والردود لا تحتاج إلا إلى رمز التفاعل.
أعدّ التطبيق في discord.com/developers/applications:
  1. General Information: انسخ معرّف التطبيق والمفتاح العام.
  2. Interactions Endpoint URL: https://<host>/channels/discord (يرسل Discord طلب PING موقَّعًا عند الحفظ).
  3. Installation: يكفي النطاق applications.commands (أضف bot فقط إن أردت أيضًا وجود مستخدم البوت في الخادم).
  4. سجّل الأمر مرة واحدة (أمر الخادم (guild) يظهر فورًا؛ واستخدم /applications/{id}/commands لأمر عام):
لا يعيش في ذاكرة المعالج إلا ask_question المعلّق (وإعادة التشغيل تُنسيه إياه)؛ أما الموافقات المعلّقة فتُحسم من نقرة الزر ومن مخزن الموافقات. وكما في Slack، متابعة التشغيل بعد إعادة التشغيل لا تُضاف إلى سجل محادثة الجلسة. ملفات channels/*.ts في مجلد الوكيل تُحمَّل قنواتٍ هي أيضًا، ويركّبها خادم node. وما زال SlackTriggerAdapter وverifySlackSignature() (انظر المُشغِّلات) يعملان للردود لمرة واحدة عبر webhook وارد.