رفتن به محتوا

پل‌های گفتگو

پل گفتگو یک محصول گفتگوی بیرونی — امروز Slack — را به راهی درجه‌یک برای ورود به فضای کاری Control Center تبدیل می‌کند. ربات را منشن کنید و عاملی بیدار می‌شود؛ در رشتهٔ Slack پاسخ دهید و گفتگو ادامه می‌یابد؛ یک دستور اسلش بنویسید و تیکتی ثبت می‌شود. در زیرِ سطح، همان فضاها، رونوشت‌ها، تیکت‌ها و انتساب‌هایی که برنامهٔ رومیزی به کار می‌برد. گفتگو یک سطح روی فضای کاری است، نه نسخه‌ای از آن.

Slack تنها ارائه‌دهنده‌ای است که عرضه می‌شود. هیچ پیاده‌سازی‌ای برای Discord، Telegram، Teams یا Matrix در هیچ‌جای محصول وجود ندارد. این قابلیت به‌عمد مستقل از ارائه‌دهنده نوشته شده است — افزودن محصول دوم یعنی یک مقدار enum، یک آداپتور و یک افزونه — اما امروز «گفتگو» یعنی Slack.

دو سطح این را پوشش می‌دهند و به افراد متفاوتی تعلق دارند. اتصال یک فضای کاری به Slack و سفارشی‌سازی ربات کارهای مدیر زیر تنظیمات → فضای کاری → عمومی است. پیوند دادن حساب گفتگوی خودتان به هویت Control Center کار یک عضو زیر تنظیمات → فضای کاری → نمایه و هویت است.

سه تصمیم کل این قابلیت را شکل می‌دهد: جهتی که اتصال در آن برقرار می‌شود، چه چیزی به پیامی که از بیرون می‌رسد مجوز می‌دهد و دانش مخصوص هر محصول اجازه دارد کجا زندگی کند.

پل هرگز به انتظار ارائه‌دهنده نمی‌نشیند. Slack از طریق Socket Mode در دسترس قرار می‌گیرد: سرور از Slack یک نشانی socket می‌خواهد و رو به بیرون تماس می‌گیرد، سپس منشن‌ها، پیام‌های مستقیم و دستورات اسلش را روی همان اتصال دریافت می‌کند.

پیامدش همان نکتهٔ اصلی است. یک لپ‌تاپ پشت NAT، یک سرور خانگی بدون دامنه، ماشینی که میان شبکه‌ها جابه‌جا می‌شود — همهٔ آن‌ها می‌توانند پل را بدون پورت ورودی، بدون تونل، بدون گواهی TLS و بدون نشانی عمومی برای چرخاندن هنگام تغییر اجرا کنند. هیچ نقطهٔ اتصال webhook برای ایمن‌کردن وجود ندارد، چون هیچ نقطهٔ اتصال webhook وجود ندارد. وقتی Control Center تعریف برنامه را می‌سازد، Socket Mode را تحمیل می‌کند و هر request URL را حذف می‌کند، چون برنامه‌ای که برای هر دو پیکربندی شده باشد برنامه‌ای است که نیمه‌کار می‌کند.

بهایش یک اتصال است که باید زنده بماند، و این کار سرور است: اتصال دوباره با عقب‌نشینی نمایی و jitter با سقف 30 ثانیه انجام می‌شود؛ چرخش socket که خود ارائه‌دهنده آغاز کند بدون تشدید آن عقب‌نشینی دوباره وصل می‌شود؛ و یک اعتبارنامهٔ ردشده پایانی است — تلاش بی‌پایان برای توکن باطل‌شده تنها یک محدودیت نرخ به دست می‌آورد، پس پل می‌ایستد و منتظر توکن‌های تازه می‌ماند و در همین حال خطای خودِ ارائه‌دهنده را عیناً در تنظیمات گزارش می‌دهد.

فضای کاری‌ای که برنامهٔ گفتگو ندارد هیچ هزینه‌ای ندارد: نه socket، نه تایمر، نه ترافیک. و کل ماجرا پس از بنر آماده‌بودن سرور شروع می‌شود، چون برنامهٔ گفتگویی که کند پاسخ می‌دهد هرگز نباید راه‌اندازی را به تأخیر بیندازد.

عضویت مجوز می‌دهد، نه در دست داشتن توکن

بخش با عنوان “عضویت مجوز می‌دهد، نه در دست داشتن توکن”

کسی که برنامه را نصب کرده همان کسی نیست که پیام را می‌فرستد. پس هر رویداد ورودی پیش از هر کاری به یک Principal تبدیل می‌شود و در حالت شکست، بسته و همراه با توضیح عمل می‌کند:

  • یک حساب گفتگوی پیوندنشده در همان رشتهٔ Slack که پیام را در آن نوشته، یاد می‌گیرد چطور پیوند بزند.
  • حساب پیوندشده‌ای که کاربرش عضو این فضای کاری نیست راهنمایی می‌شود از یک مدیر درخواست دعوت کند.
  • عضوی که نقشش فقط‌خواندنی است (viewer، guest) می‌فهمد که نقشش نمی‌تواند کاری را شروع کند.

هیچ‌یک از این‌ها پیش نمی‌روند. پیام گفتگو هرگز به کسی که آن را نفرستاده نسبت داده نمی‌شود — و همین است که رد حسابرسی، محدودیت‌های نرخ هر Principal و گزارش‌دهندگان تیکت را صادق نگه می‌دارد. برای مدل عضویتی که این بر آن تکیه دارد، چندنفره — هویت، عضویت و حضور را ببینید.

پیوند، تشخیص هویت است، نه اعطای دسترسی. عضویت در هر رویداد دوباره بررسی می‌شود، پس پیوندی که به کسی تعلق دارد که از آن پس حذف شده، به یک رد می‌انجامد نه به امتیازهای پیشین او. دو مسیر پیوند برقرار می‌کند: کدی کوتاه‌عمر و یک‌بارمصرف که عضو در گفتگو می‌نویسد، یا تطبیق خودکار بر پایهٔ ایمیل تأییدشدهٔ ارائه‌دهنده — و مسیر ایمیل هنوز لازم دارد که کاربر منطبق‌شده از پیش عضو باشد، پس غریبه‌ای که نشانی‌اش تصادفاً روی سرور وجود دارد به هیچ‌چیز پیوند نمی‌خورد.

کدها به‌عمد در حافظه نگه داشته می‌شوند، با زمان بقای 15 دقیقه. ذخیرهٔ دائمی یکی از آن‌ها ردیفی پایدار به شکل اعتبارنامه برای چیزی می‌ساخت که قرار است تبخیر شود، و اینکه راه‌اندازی دوبارهٔ سرور همهٔ کدهای باقی‌مانده را باطل کند رفتار درست است، نه پسرفت — عضو دوباره «پیوند» را می‌زند.

رد، در همان رشتهٔ Slack که پیام باز کرده و به‌شکل پاسخی واقعی داده می‌شود. پاسخ رشته‌ای پیامی را که به آن جواب می‌دهد نشانه می‌زند، به همان یک نفری می‌رسد که لازمش دارد و باز هم «تو پیوند نداری» را از خودِ کانال بیرون نگه می‌دارد. فرستادن پیام همچنین تنها قابلیتی است که هر ارائه‌دهنده دارد، پس توضیح هرگز به قابلیتی که برنامه ممکن است نداشته باشد وابسته نمی‌شود.

پلی که هر دو سو را آینه می‌کند تا وقتی چیزی دو جهت را از هم جدا نکند، در حلقه می‌افتد. هر پیامی که پل وارد می‌کند با metadata['chat'] مهر می‌شود — ارائه‌دهنده، تیم، گفتگو، رشته، پیام و فرستنده. آینهٔ خروجی از هر پیامی که این مهر را داشته باشد می‌گذرد.

همین یک قاعده است که آینه‌سازی دوسویه را ایمن می‌کند: پیامی که یک انسان در Control Center می‌نویسد به رشتهٔ پیوندشده رله می‌شود (با نام نویسنده در آغاز)، در حالی که پیامی که از گفتگو آمده، همان‌جا که وارد شده می‌ایستد.

آنچه رو به بیرون می‌رود به‌عمد باریک است. پاسخ عامل به‌شکل متن معمولی گفتگو رله می‌شود. یک اندیشهٔ کوتاه می‌تواند روی ردیف Thinking… کارت ظاهر شود. خروجی ابزار دیواری از diffها است و در Control Center می‌ماند، و پاسخ بریده هم به همان‌جا اشاره می‌کند.

کارت کار را گزارش می‌کند؛ متن هنوز همان پیام است

بخش با عنوان “کارت کار را گزارش می‌کند؛ متن هنوز همان پیام است”

جایی که ارائه‌دهنده آن‌ها را نمایش می‌دهد، هر نوبت یک کارت وظیفه دارد که تا وقتی نوبت در جریان است در جای خود ویرایش می‌شود و بالای پاسخی که توضیح می‌دهد می‌نشیند.

کارت یک طرح گروه‌بندی‌شده است، نه پاراگرافی به‌هم‌چسبیده. Slack در همان وظیفه details و output را به هم می‌چسباند، پس جزئیات یک ردیف یک‌بار فرستاده می‌شود (اندیشه روی Thinking…) و output هرگز استریم نمی‌شود. راه‌اندازی یک ردیف است که عنوانش جایگزین می‌شود (Working on it…، Cloning …). استدلال یک ردیف Thinking… اضافه می‌کند. هر ابزار ردیف خودش را دارد (Read lib/main.dart). پاسخ متن معمولی زیر کارت است — نمای طرح Slack آن را از یک افزودن markdown بعدی می‌خواند، نه از تکه‌ای که میان وظیفه‌ها مخلوط شده باشد. چیز دیگری نه: نه خروجی ابزار و تنها یک آرگومان ابزار از یک فهرست مجاز کوتاه، چون آرگومان‌های یک ابزار ناشناخته می‌توانند بدنهٔ یک فایل کامل باشند و یک ردیف یک خط است.

کارت همچنین انتظار پیش از نوبت را پوشش می‌دهد. اولین منشن گفتگو را می‌سازد و ساختنش هر مخزنی را که عامل در آن کار خواهد کرد کلون می‌کند — که روی یک مخزن واقعی چند دقیقه است که هنوز هیچ‌چیز از عامل پرسیده نشده و رشته طوری به نظر می‌رسد که ربات درخواست را نادیده گرفته است. پس کارت همان لحظه‌ای باز می‌شود که منشن پذیرفته می‌شود (Working on it…) و بعد آن ردیف راه‌اندازی با همان واژه‌هایی به‌روز می‌شود که برنامهٔ رومیزی نشان می‌دهد — Cloning acme/widgets…، Setting up Reviewer…، Starting the agent… — سپس به‌محض اینکه عامل شروع به استدلال کند یک ردیف Thinking…، و بعد یک ردیف برای هر ابزار. یک کارت، یک پیام، از «شنیدمت» تا پاسخ به‌شکل متن معمولی در زیر آن. راه‌اندازی فقط جایی گزارش می‌شود که کسی در گفتگو منتظرش است: گفتگویی که در برنامه آماده می‌شود، یا گفتگویی سرگردان که هنگام راه‌اندازی سرور از سر گرفته می‌شود، بدون هیچ کارتی آماده‌سازی می‌شود.

کارت تنها چیزی را حمل می‌کند که گفتگو نمی‌تواند به خواننده بدهد: پیوند View in Control Center به گفتگویی که پشت پاسخ است. محصولات گفتگو در یک پیوند فقط http(s) را می‌پذیرند و نه چیز دیگری، پس دکمه به صفحهٔ کوچکی اشاره می‌کند که همین سرور ارائه می‌دهد و مرورگر را به پیوند control-center:// برنامهٔ رومیزی می‌فرستد. آن صفحه چیزی نمی‌خواند و چیزی را حل نمی‌کند — شناسه‌های موجود در URL‌اش فقط به یک پیوند عمیق بازتاب داده می‌شوند و جای دیگری نه — و سروری که نشانی عمومی قابل‌دسترسی ندارد، به‌جای کارتی خراب، صرفاً کارتی بدون دکمه تولید می‌کند.

کارت‌ها هم مثل هر قابلیت دیگری‌اند. ارائه‌دهنده‌ای که آن‌ها را ندارد دقیقاً مثل قبل رفتار می‌کند، فقط متن؛ و ارائه‌دهنده‌ای که هنگام فراخوانی یکی را رد کند، استریمش را نگه می‌دارد: پاسخ همیشه از کارتی که توصیفش می‌کند بالاتر است. وقتی استریم کاملاً خاموش باشد، کارت تمام‌شده به‌جای آن سوار همان پیام واحد فرستاده‌شده می‌شود. تیکتی که ثبت می‌شود همین رفتار را می‌گیرد — تأیید /cc ticket یک کارت کامل‌شده است که دلیل وجودش پیوند به تیکتی است که همین حالا ساخته شده.

قابلیت‌ها مذاکره می‌شوند، سپس به یاد می‌مانند

بخش با عنوان “قابلیت‌ها مذاکره می‌شوند، سپس به یاد می‌مانند”

محصولات گفتگو در اینکه چه چیزی را می‌توانند نمایش دهند فرق دارند، پس پل بر پایهٔ پرمایه‌ترین سطح نوشته شده است — پاسخی که درون یک رشته زنده رشد می‌کند، یک وضعیت گذرای «is thinking…»، رشته‌ای عنوان‌دار، پاسخی گذرا به یک دستور اسلش — و هر بار یک قابلیت را تنزل می‌دهد. ارائه‌دهنده‌ای که هیچ‌یک از این‌ها را ندارد هم کار می‌کند؛ پاسخ‌ها صرفاً به‌شکل یک پیام می‌رسند.

اعلام یک قابلیت، وعده دادن آن نیست. پاسخ‌های استریم‌شدهٔ Slack به قابلیتی در طرح پولی نیاز دارند و Slack فقط هنگام فراخوانی رد می‌کند. ردی که به‌عنوان دائمی علامت خورده باشد، در تمام عمر اتصال به یاد می‌ماند: پل از پرسیدن دست می‌کشد و از آن پس پاسخ‌های کامل را می‌فرستد و تنظیمات هم صریح همین را می‌گوید، چون باید بتوان بدون خواندن log به «پاسخ‌هایم دیگر استریم نمی‌شوند» پاسخ داد. کارت‌های وظیفه هم اگر Slack یکی را رد کند به همین شکل کنار گذاشته می‌شوند.

هر محدودیت به ارائه‌دهنده تعلق دارد نه به رله: سقف پیام، سقف تکهٔ استریم و اینکه یک استریم به لنگر رشته نیاز دارد یا نه، همه را آداپتور اعلام می‌کند و پاسخ بلند روی مرز پاراگراف، خط یا واژه شکسته می‌شود، نه در میانهٔ واژه.

یک پل، یک آداپتور به‌ازای هر محصول

بخش با عنوان “یک پل، یک آداپتور به‌ازای هر محصول”

هر چیزی که شکل محصول دارد، پشت یک پورت زندگی می‌کند. هستهٔ عمومی markdown، گفتگوهای چت، رشته‌های چت و اعضا را می‌شناسد؛ اما هیچ‌چیز دربارهٔ پاکت‌های Slack، mrkdwn یا gateway intents نمی‌داند. سه قطعه این را ممکن می‌کند:

  • آداپتور یک محصول را بهنجار می‌کند: ترابری‌اش، فراخوانی‌های API‌اش، کدک متنش و عجایب پاکتش. عجایب Slack آموزنده‌اند — منشن کانال دو بار می‌رسد (یک‌بار به‌شکل app_mention و یک‌بار به‌شکل message) و بیشتر زیرگونه‌های پیام تزئینات‌اند نه درخواست‌های تازه. هر دو در آداپتور حل می‌شوند، پس هسته هرگز نمی‌فهمد که Slack دوبار تحویل می‌دهد.
  • توصیف‌گر شکل عمومی ارائه‌دهنده است: به کدام اعتبارنامه‌ها نیاز دارد، قالب آن‌ها چیست، کدام URLها به کاربر در یافتن‌شان کمک می‌کنند، چه کارهایی می‌تواند بکند و آیا برنامه‌اش می‌تواند از اینجا ساخته یا ویرایش شود. کارت تنظیمات، دیالوگ اتصال و اعتبارسنجی آرگومان همه از آن تولید می‌شوند — کلاینت یک ارائه‌دهنده به‌همراه یک نگاشتی که کلیدش شناسهٔ فیلد است بازمی‌فرستد.
  • افزونه ترکیب سمت سرور است: چگونه اعتبارنامه‌ها به اتصالی تأییدشده تبدیل می‌شوند، چگونه یک آداپتور ساخته می‌شود و چگونه برنامهٔ سمت ارائه‌دهنده مدیریت می‌شود.

پس افزودن یک محصول یعنی یک مقدار enum، یک آداپتور و یک افزونه. نه عملیات RPC تازه، نه تغییر schema، نه تغییر کلاینت — و کلاینت صادق می‌ماند، چون فیلدی که هرگز نامش را نشنیده باز هم با برچسبی که سرور داده نمایش داده می‌شود.

دو جزئیات نمی‌گذارند این انتزاع به بالا نشت کند. گام‌های مدیریت برنامه‌ای که ارائه‌دهنده برایشان API ندارد (تولید توکن، نصب برنامه، نصب دوباره پس از تغییر مجوزها) به‌شکل گام‌های نام‌دار با پیوندهای خودشان بازگردانده می‌شوند، پس صفحهٔ پایان راه‌اندازی آن‌ها را بدون دانستن اینکه با کدام ارائه‌دهنده روبه‌روست نمایش می‌دهد. و هر دستورالعملی که پل می‌نویسد نام ارائه‌دهنده و فرمانی را که برنامه واقعاً به کار می‌برد ذکر می‌کند، پس فرمانی که نامش عوض شده یا ارائه‌دهندهٔ دومی درست خوانده می‌شود، بدون هیچ رشتهٔ تازه.

اعتبارنامه‌ها کجا زندگی می‌کنند

بخش با عنوان “اعتبارنامه‌ها کجا زندگی می‌کنند”

توکن‌های گفتگوی یک فضای کاری در پوشهٔ خودِ آن فضای کاری، کنار پایگاه داده‌اش می‌نشینند — به‌شکل JSON ساده در <dataDir>/<workspaceId>/chat_credentials/<provider>.json، نه در پایگاه داده و به‌عمد نه در جاکلید سیستم‌عامل. دو پیامد را بعدها آسان می‌توان از دست داد: حذف یک فضای کاری پوشه را unlink می‌کند و توکن‌هایش همراهش می‌روند، و برون‌بری یا پشتیبان‌گیری از یک فضای کاری workspace.db را کپی می‌کند، پس فضای کاری‌ای که به کس دیگری سپرده می‌شود توکن‌های ربات زنده در خود حمل نمی‌کند. این فایل مرز اعتماد فایل‌سیستم میزبان سرور را به ارث می‌برد — هر چیزی که دسترسی خواندن به پوشهٔ داده دارد می‌تواند یک توکن ربات را بخواند، که همان مرزی است که پایگاه‌های داده پشت آن می‌نشینند.

یک فایل به‌ازای هر ارائه‌دهنده، که به‌صورت اتمیک و فقط برای مالک نوشته می‌شود: برنامه‌ای که مجوزش لغو شده با unlink کردن یک فایل حذف می‌شود و یک فایل خراب تنها یک ارائه‌دهنده را هزینه می‌آورد نه همه را. اعتبارنامه‌ها داخل از طریق عملیات اتصال می‌آیند و هرگز برنمی‌گردند — خواندن وضعیت فقط با فرادادهٔ اتصال پاسخ می‌دهد.

  • تنظیمات ساختاری برنامه. ترابری، scopeها و اشتراک‌های رویداد تحمیل می‌شوند، نه آشکار. برنامه‌ای که از یک صفحهٔ تنظیمات می‌توان به سکوت کشاندش یک تیکت پشتیبانی است، نه یک قابلیت.
  • گفتگو به‌علاوهٔ دستورات اسلش. سطح Slack پیام‌ها، پاسخ‌های رشته‌ای و دستورات اسلش است.
  • منبع حقیقت دوم. گفتگو جایی نیست که کار در آن ذخیره شود. رشتهٔ Slack یک فضا است، پاسخ یک رونوشت است، تیکت یک تیکت است — همه‌شان در فضای کاری و از هر کلاینتی قابل‌دسترس.