Merge PR #1572: guided Web UI config for Feishu
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
6d4e7834af
27 files changed
+1668
-15
No files matched your search
@@ -1251,7 +1251,7 @@ Commands are case-insensitive and must be sent as the entire message (e.g. `hell
|
||||
|
||||
> **`/pairing` is operator-gated.** In-chat pairing-control commands are rejected unless the sender is listed as an operator — set `channels.operators` (cross-channel authority) or a channel section's own `operators` list in your channels config. With no operators configured, in-chat `/pairing` is refused (fail-closed) and pairing is managed only through the authenticated CLI (`vibe-trading channels pairing …`) and the auth-gated REST endpoint. This prevents any allow-listed group member from taking over pairing across channels.
|
||||
|
||||
**Configure from the Web UI**: the **IM Channels** panel on the Settings page configures channels without hand-editing files. Expand a channel to open its config panel: fields render from backend metadata, and secret values are never returned to the browser, only masked (`****` plus the last 4 characters). DingTalk is the first fully-guided channel: create an app at [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), add the bot capability, enable Stream Mode (no public callback URL needed), copy the AppKey into Client ID and the AppSecret into Client Secret, then publish the app. QQ has joined it as a fully-guided channel: register a bot on the QQ Open Platform ([q.qq.com](https://q.qq.com/)), copy the AppID and AppSecret into the form, verify with **Test connection** before saving, then enable; no public callback URL is needed (WebSocket via the official botpy SDK). Email and WebSocket have joined as fully-guided channels: Email's **Test connection** probes the IMAP login, the mailbox folder and the SMTP login against your provider without sending anything, while WebSocket — a local server rather than a remote service — validates the TLS certificate/key material and the address availability on this machine; an address held by the already-running server reads as the expected state, not a failure. Saving a WebSocket config hot-swaps the server, so connected clients (including the Web UI chat) briefly disconnect and reconnect. **Test connection** probes the values in the form before anything is saved and answers with an honest result code (`ok`, `invalid_credentials`, `network`, or `unsupported`). **Enable** applies immediately: a running channel runtime hot-swaps that one adapter with no process restart, and enabling auto-verifies credentials unless you explicitly choose **Enable anyway** after a failed check. Disabling a channel keeps its stored credentials, so re-enabling needs no re-entry. Only the built-in adapters are listed here; entry-points plugin channels remain file-configured.
|
||||
**Configure from the Web UI**: the **IM Channels** panel on the Settings page configures channels without hand-editing files. Expand a channel to open its config panel: fields render from backend metadata, and secret values are never returned to the browser, only masked (`****` plus the last 4 characters). DingTalk is the first fully-guided channel: create an app at [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), add the bot capability, enable Stream Mode (no public callback URL needed), copy the AppKey into Client ID and the AppSecret into Client Secret, then publish the app. QQ has joined it as a fully-guided channel: register a bot on the QQ Open Platform ([q.qq.com](https://q.qq.com/)), copy the AppID and AppSecret into the form, verify with **Test connection** before saving, then enable; no public callback URL is needed (WebSocket via the official botpy SDK). Email and WebSocket have joined as fully-guided channels: Email's **Test connection** probes the IMAP login, the mailbox folder and the SMTP login against your provider without sending anything, while WebSocket — a local server rather than a remote service — validates the TLS certificate/key material and the address availability on this machine; an address held by the already-running server reads as the expected state, not a failure. Saving a WebSocket config hot-swaps the server, so connected clients (including the Web UI chat) briefly disconnect and reconnect. Feishu has joined them as a fully-guided channel: create a custom app on the Feishu Open Platform ([open.feishu.cn](https://open.feishu.cn/), or [open.larksuite.com](https://open.larksuite.com/) for international Lark), add the bot capability, select the matching domain in the form, copy the App ID and App Secret, verify with **Test connection** before saving, then enable; no public callback URL is needed (WebSocket long connection via the official lark-oapi SDK). **Test connection** probes the values in the form before anything is saved and answers with an honest result code (`ok`, `invalid_credentials`, `network`, or `unsupported`). **Enable** applies immediately: a running channel runtime hot-swaps that one adapter with no process restart, and enabling auto-verifies credentials unless you explicitly choose **Enable anyway** after a failed check. Disabling a channel keeps its stored credentials, so re-enabling needs no re-entry. Only the built-in adapters are listed here; entry-points plugin channels remain file-configured.
|
||||
|
||||
Saves write the `channels.<name>` section of `~/.vibe-trading/agent.json` atomically. YAML config files are read-only for the web UI (JSON is required to edit from the browser), and the panel says so instead of failing. Channels without a dedicated guide render a generic form derived from their defaults.
|
||||
|
||||
|
||||
+1
-1
@@ -1048,7 +1048,7 @@ vibe-trading channels pairing --channel telegram list
|
||||
|
||||
الأوامر لا تحس بحالة الأحرف ويجب إرسالها كرسالة كاملة (مثلاً `hello /new` تُعامل كرسالة عادية وليس كأمر إعادة تعيين).
|
||||
|
||||
**التكوين من واجهة الويب**: تتيح لوحة **IM Channels** في صفحة Settings ضبط القنوات دون تحرير الملفات يدويًا. افتح قناةً لتظهر لوحة تكوينها: تُرسم الحقول من بيانات وصفية يوفّرها الخادم، ولا تُرسل قيم الأسرار إلى المتصفح أبدًا، بل تظهر مقنّعة (`****` مع آخر 4 محارف). DingTalk هي أول قناة يغطيها دليل إعداد كامل: أنشئ تطبيقًا في [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)، ثم أضف إمكانية الروبوت وفعّل وضع Stream Mode (لا حاجة إلى عنوان callback عام)، وانسخ AppKey إلى Client ID وAppSecret إلى Client Secret، ثم انشر التطبيق. انضمّت QQ إليها كقناة يغطيها دليل إعداد كامل: سجّل روبوتًا في منصة QQ Open Platform ([q.qq.com](https://q.qq.com/))، وانسخ AppID وAppSecret إلى النموذج، وتحقّق عبر **Test connection** قبل الحفظ ثم فعّل؛ لا حاجة إلى عنوان callback عام (WebSocket عبر حزمة botpy الرسمية). انضمّت Email و WebSocket أيضًا كقناتين يغطيهما دليل إعداد كامل: يختبر **Test connection** لدى Email تسجيل الدخول عبر IMAP ومجلد صندوق البريد وتسجيل الدخول عبر SMTP لدى مزوّد خدمتك دون إرسال أي شيء، بينما يتحقق WebSocket — وهو خادم محلي وليس خدمة عن بُعد — من مواد شهادة/مفتاح TLS ومن توفّر العنوان على هذا الجهاز؛ والعنوان المحتلّ من الخادم قيد التشغيل أصلًا يُقرأ كحالة متوقعة وليس كفشل. يؤدي حفظ إعداد WebSocket إلى تبديل الخادم على الساخن، لذا ينفصل العملاء المتصلون (بما في ذلك محادثة واجهة الويب) لفترة وجيزة ثم يعيدون الاتصال. يختبر زر **Test connection** قيم النموذج الحالية قبل حفظ أي شيء، ويعيد رمز نتيجة صادقًا (`ok` أو `invalid_credentials` أو `network` أو `unsupported`). يُطبَّق خيار **Enable** فورًا: بيئة تشغيل القنوات القائمة تبدّل هذا المحوّل وحده دون إعادة تشغيل العملية، والتحقق من بيانات الاعتماد يتم تلقائيًا عند التفعيل إلا إذا اخترت صراحةً **Enable anyway** بعد فشل التحقق. الإيقاف يبقي بيانات الاعتماد المخزنة، لذا لا حاجة لإعادة إدخالها عند التفعيل مجددًا. تُعرض هنا المحوّلات المدمجة فقط؛ وتبقى قنوات الإضافات المضافة عبر نقاط الدخول (entry points) قابلة للتهيئة من الملفات فقط.
|
||||
**التكوين من واجهة الويب**: تتيح لوحة **IM Channels** في صفحة Settings ضبط القنوات دون تحرير الملفات يدويًا. افتح قناةً لتظهر لوحة تكوينها: تُرسم الحقول من بيانات وصفية يوفّرها الخادم، ولا تُرسل قيم الأسرار إلى المتصفح أبدًا، بل تظهر مقنّعة (`****` مع آخر 4 محارف). DingTalk هي أول قناة يغطيها دليل إعداد كامل: أنشئ تطبيقًا في [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)، ثم أضف إمكانية الروبوت وفعّل وضع Stream Mode (لا حاجة إلى عنوان callback عام)، وانسخ AppKey إلى Client ID وAppSecret إلى Client Secret، ثم انشر التطبيق. انضمّت QQ إليها كقناة يغطيها دليل إعداد كامل: سجّل روبوتًا في منصة QQ Open Platform ([q.qq.com](https://q.qq.com/))، وانسخ AppID وAppSecret إلى النموذج، وتحقّق عبر **Test connection** قبل الحفظ ثم فعّل؛ لا حاجة إلى عنوان callback عام (WebSocket عبر حزمة botpy الرسمية). انضمّت Email و WebSocket أيضًا كقناتين يغطيهما دليل إعداد كامل: يختبر **Test connection** لدى Email تسجيل الدخول عبر IMAP ومجلد صندوق البريد وتسجيل الدخول عبر SMTP لدى مزوّد خدمتك دون إرسال أي شيء، بينما يتحقق WebSocket — وهو خادم محلي وليس خدمة عن بُعد — من مواد شهادة/مفتاح TLS ومن توفّر العنوان على هذا الجهاز؛ والعنوان المحتلّ من الخادم قيد التشغيل أصلًا يُقرأ كحالة متوقعة وليس كفشل. يؤدي حفظ إعداد WebSocket إلى تبديل الخادم على الساخن، لذا ينفصل العملاء المتصلون (بما في ذلك محادثة واجهة الويب) لفترة وجيزة ثم يعيدون الاتصال. وانضمّت Feishu إليها كقناة يغطيها دليل إعداد كامل: أنشئ تطبيقًا مخصصًا في منصة Feishu Open Platform ([open.feishu.cn](https://open.feishu.cn/)، أو [open.larksuite.com](https://open.larksuite.com/) لنسخة Lark الدولية)، وأضف إمكانية الروبوت، واختر النطاق المقابل في النموذج، وانسخ App ID وApp Secret إلى النموذج، وتحقّق عبر **Test connection** قبل الحفظ ثم فعّل؛ لا حاجة إلى عنوان callback عام (اتصال WebSocket طويل عبر حزمة lark-oapi الرسمية). يختبر زر **Test connection** قيم النموذج الحالية قبل حفظ أي شيء، ويعيد رمز نتيجة صادقًا (`ok` أو `invalid_credentials` أو `network` أو `unsupported`). يُطبَّق خيار **Enable** فورًا: بيئة تشغيل القنوات القائمة تبدّل هذا المحوّل وحده دون إعادة تشغيل العملية، والتحقق من بيانات الاعتماد يتم تلقائيًا عند التفعيل إلا إذا اخترت صراحةً **Enable anyway** بعد فشل التحقق. الإيقاف يبقي بيانات الاعتماد المخزنة، لذا لا حاجة لإعادة إدخالها عند التفعيل مجددًا. تُعرض هنا المحوّلات المدمجة فقط؛ وتبقى قنوات الإضافات المضافة عبر نقاط الدخول (entry points) قابلة للتهيئة من الملفات فقط.
|
||||
|
||||
عند الحفظ يُحدَّث قسم `channels.<name>` في `~/.vibe-trading/agent.json` بشكل ذرّي. ملفات إعداد YAML للقراءة فقط في واجهة الويب (التحرير من المتصفح يتطلب JSON)، وتعرض اللوحة هذه الرسالة بدلاً من الفشل. القنوات التي لا تملك دليلًا مخصصًا تعرض نماذج عامة مشتقة من إعداداتها الافتراضية.
|
||||
|
||||
|
||||
+1
-1
@@ -1103,7 +1103,7 @@ Los comandos no distinguen mayúsculas/minúsculas y deben enviarse como el mens
|
||||
|
||||
> **`/pairing` está restringido a operadores.** Los comandos de control de emparejamiento dentro del chat se rechazan a menos que el remitente figure como operador — configura `channels.operators` (autoridad entre canales) o la propia lista `operators` de la sección de un canal en tu configuración de canales. Sin operadores configurados, `/pairing` dentro del chat se rechaza (fail-closed) y el emparejamiento solo se gestiona a través de la CLI autenticada (`vibe-trading channels pairing …`) y el endpoint REST protegido por autenticación. Esto evita que cualquier miembro de un grupo en la lista de permitidos pueda tomar control del emparejamiento entre canales.
|
||||
|
||||
**Configuración desde la Web UI**: el panel **IM Channels** de la página Settings ahora configura canales sin editar archivos a mano. Despliega un canal para abrir su panel de configuración: los campos se renderizan desde metadatos del backend, y los valores secretos nunca se devuelven al navegador, solo enmascarados (`****` más los últimos 4 caracteres). DingTalk es el primer canal con guía completa: crea una aplicación en [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), agrega la capacidad de bot, habilita Stream Mode (no hace falta una URL de callback pública), copia el AppKey a Client ID y el AppSecret a Client Secret, y publica la aplicación. QQ se suma como canal con guía completa: registra un bot en la QQ Open Platform ([q.qq.com](https://q.qq.com/)), copia el AppID y el AppSecret al formulario, verifica con **Test connection** antes de guardar y habilita; no hace falta una URL de callback pública (WebSocket mediante el SDK oficial botpy). Email y WebSocket se suman también como canales con guía completa: **Test connection** de Email prueba el inicio de sesión IMAP, la carpeta del buzón y el inicio de sesión SMTP contra tu proveedor sin enviar nada, mientras que WebSocket —un servidor local en lugar de un servicio remoto— valida el material de certificado/clave TLS y la disponibilidad de la dirección en esta máquina; una dirección ocupada por el servidor ya en ejecución se lee como el estado esperado, no como un fallo. Guardar una configuración de WebSocket intercambia el servidor en caliente, así que los clientes conectados (incluido el chat de la Web UI) se desconectan y reconectan brevemente. **Test connection** prueba los valores del formulario antes de guardar cualquier cosa y responde con un código honesto (`ok`, `invalid_credentials`, `network` o `unsupported`). **Enable** se aplica de inmediato: un runtime de canales en ejecución intercambia en caliente solo ese adaptador sin reiniciar el proceso, y al habilitar se verifican las credenciales automáticamente salvo que elijas explícitamente **Enable anyway** tras una verificación fallida. Deshabilitar conserva las credenciales almacenadas, así que reactivar no exige volver a escribirlas. Aquí solo se enumeran los adaptadores integrados; los canales de plugins vía entry points siguen configurándose por archivo.
|
||||
**Configuración desde la Web UI**: el panel **IM Channels** de la página Settings ahora configura canales sin editar archivos a mano. Despliega un canal para abrir su panel de configuración: los campos se renderizan desde metadatos del backend, y los valores secretos nunca se devuelven al navegador, solo enmascarados (`****` más los últimos 4 caracteres). DingTalk es el primer canal con guía completa: crea una aplicación en [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), agrega la capacidad de bot, habilita Stream Mode (no hace falta una URL de callback pública), copia el AppKey a Client ID y el AppSecret a Client Secret, y publica la aplicación. QQ se suma como canal con guía completa: registra un bot en la QQ Open Platform ([q.qq.com](https://q.qq.com/)), copia el AppID y el AppSecret al formulario, verifica con **Test connection** antes de guardar y habilita; no hace falta una URL de callback pública (WebSocket mediante el SDK oficial botpy). Email y WebSocket se suman también como canales con guía completa: **Test connection** de Email prueba el inicio de sesión IMAP, la carpeta del buzón y el inicio de sesión SMTP contra tu proveedor sin enviar nada, mientras que WebSocket —un servidor local en lugar de un servicio remoto— valida el material de certificado/clave TLS y la disponibilidad de la dirección en esta máquina; una dirección ocupada por el servidor ya en ejecución se lee como el estado esperado, no como un fallo. Guardar una configuración de WebSocket intercambia el servidor en caliente, así que los clientes conectados (incluido el chat de la Web UI) se desconectan y reconectan brevemente. Feishu se suma como canal con guía completa: crea una aplicación personalizada en la Feishu Open Platform ([open.feishu.cn](https://open.feishu.cn/), o [open.larksuite.com](https://open.larksuite.com/) para Lark internacional), agrega la capacidad de bot, selecciona el dominio correspondiente en el formulario, copia el App ID y el App Secret, verifica con **Test connection** antes de guardar y habilita; no hace falta una URL de callback pública (conexión prolongada WebSocket mediante el SDK oficial lark-oapi). **Test connection** prueba los valores del formulario antes de guardar cualquier cosa y responde con un código honesto (`ok`, `invalid_credentials`, `network` o `unsupported`). **Enable** se aplica de inmediato: un runtime de canales en ejecución intercambia en caliente solo ese adaptador sin reiniciar el proceso, y al habilitar se verifican las credenciales automáticamente salvo que elijas explícitamente **Enable anyway** tras una verificación fallida. Deshabilitar conserva las credenciales almacenadas, así que reactivar no exige volver a escribirlas. Aquí solo se enumeran los adaptadores integrados; los canales de plugins vía entry points siguen configurándose por archivo.
|
||||
|
||||
Guardar actualiza de forma atómica la sección `channels.<name>` de `~/.vibe-trading/agent.json`. Los archivos YAML son de solo lectura para la web UI (se requiere JSON para editar desde el navegador), y el panel lo indica en lugar de fallar. Los canales sin guía dedicada muestran un formulario genérico derivado de su configuración por defecto.
|
||||
|
||||
|
||||
+1
-1
@@ -1292,7 +1292,7 @@ Perintah tidak peka huruf besar/kecil dan harus dikirim sebagai seluruh isi pesa
|
||||
|
||||
> **`/pairing` dibatasi untuk operator.** Perintah kontrol pairing di chat ditolak kecuali sender terdaftar sebagai operator — atur `channels.operators` (otoritas lintas-channel) atau daftar `operators` milik section channel di config Anda. Jika tidak ada operator yang dikonfigurasi, `/pairing` di chat ditolak secara fail-closed dan pairing hanya dikelola melalui CLI terautentikasi (`vibe-trading channels pairing …`) dan endpoint REST yang dilindungi auth. Ini mencegah anggota grup yang hanya di-allowlist mengambil alih pairing antar-channel.
|
||||
|
||||
**Konfigurasi dari Web UI**: panel **IM Channels** di halaman Settings mengonfigurasi channel tanpa mengedit file secara manual. Buka sebuah channel untuk melihat panel konfigurasinya: field dirender dari metadata backend, dan nilai secret tidak pernah dikirim kembali ke browser, hanya versi tersamarkan (`****` ditambah 4 karakter terakhir). DingTalk adalah channel pertama dengan panduan lengkap: buat app di [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), tambahkan kemampuan bot, aktifkan Stream Mode (tidak perlu URL callback publik), salin AppKey ke Client ID dan AppSecret ke Client Secret, lalu publikasikan app. QQ menyusul sebagai channel dengan panduan lengkap: daftarkan bot di QQ Open Platform ([q.qq.com](https://q.qq.com/)), salin AppID dan AppSecret ke formulir, verifikasi dengan **Test connection** sebelum menyimpan, lalu aktifkan; tidak perlu URL callback publik (WebSocket melalui SDK resmi botpy). Email dan WebSocket telah bergabung sebagai channel dengan panduan lengkap: **Test connection** milik Email menguji login IMAP, folder mailbox, dan login SMTP ke penyedia Anda tanpa mengirim apa pun, sementara WebSocket — sebuah server lokal, bukan layanan jarak jauh — memverifikasi materi sertifikat/kunci TLS dan ketersediaan alamat di mesin ini; alamat yang dipegang oleh server yang sedang berjalan terbaca sebagai kondisi yang diharapkan, bukan kegagalan. Menyimpan konfigurasi WebSocket mengganti server secara hot-swap, sehingga klien yang terhubung (termasuk chat Web UI) terputus sebentar lalu menyambung kembali. **Test connection** menguji nilai di formulir sebelum apa pun disimpan dan menjawab dengan kode hasil yang jujur (`ok`, `invalid_credentials`, `network`, atau `unsupported`). **Enable** langsung berlaku: runtime channel yang sedang berjalan mengganti adapter tersebut saja tanpa restart proses, dan pengaktifan memverifikasi kredensial secara otomatis kecuali Anda memilih **Enable anyway** setelah pemeriksaan gagal. Menonaktifkan channel tetap menyimpan kredensialnya, sehingga mengaktifkannya kembali tidak perlu mengisi ulang. Hanya adapter bawaan yang tercantum di sini; channel plugin dari entry-points tetap dikonfigurasi lewat file.
|
||||
**Konfigurasi dari Web UI**: panel **IM Channels** di halaman Settings mengonfigurasi channel tanpa mengedit file secara manual. Buka sebuah channel untuk melihat panel konfigurasinya: field dirender dari metadata backend, dan nilai secret tidak pernah dikirim kembali ke browser, hanya versi tersamarkan (`****` ditambah 4 karakter terakhir). DingTalk adalah channel pertama dengan panduan lengkap: buat app di [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), tambahkan kemampuan bot, aktifkan Stream Mode (tidak perlu URL callback publik), salin AppKey ke Client ID dan AppSecret ke Client Secret, lalu publikasikan app. QQ menyusul sebagai channel dengan panduan lengkap: daftarkan bot di QQ Open Platform ([q.qq.com](https://q.qq.com/)), salin AppID dan AppSecret ke formulir, verifikasi dengan **Test connection** sebelum menyimpan, lalu aktifkan; tidak perlu URL callback publik (WebSocket melalui SDK resmi botpy). Email dan WebSocket telah bergabung sebagai channel dengan panduan lengkap: **Test connection** milik Email menguji login IMAP, folder mailbox, dan login SMTP ke penyedia Anda tanpa mengirim apa pun, sementara WebSocket — sebuah server lokal, bukan layanan jarak jauh — memverifikasi materi sertifikat/kunci TLS dan ketersediaan alamat di mesin ini; alamat yang dipegang oleh server yang sedang berjalan terbaca sebagai kondisi yang diharapkan, bukan kegagalan. Menyimpan konfigurasi WebSocket mengganti server secara hot-swap, sehingga klien yang terhubung (termasuk chat Web UI) terputus sebentar lalu menyambung kembali. Feishu menyusul sebagai channel dengan panduan lengkap: buat aplikasi kustom di Feishu Open Platform ([open.feishu.cn](https://open.feishu.cn/), atau [open.larksuite.com](https://open.larksuite.com/) untuk Lark internasional), tambahkan kemampuan bot, pilih domain yang sesuai di formulir, salin App ID dan App Secret, verifikasi dengan **Test connection** sebelum menyimpan, lalu aktifkan; tidak perlu URL callback publik (long connection WebSocket melalui SDK resmi lark-oapi). **Test connection** menguji nilai di formulir sebelum apa pun disimpan dan menjawab dengan kode hasil yang jujur (`ok`, `invalid_credentials`, `network`, atau `unsupported`). **Enable** langsung berlaku: runtime channel yang sedang berjalan mengganti adapter tersebut saja tanpa restart proses, dan pengaktifan memverifikasi kredensial secara otomatis kecuali Anda memilih **Enable anyway** setelah pemeriksaan gagal. Menonaktifkan channel tetap menyimpan kredensialnya, sehingga mengaktifkannya kembali tidak perlu mengisi ulang. Hanya adapter bawaan yang tercantum di sini; channel plugin dari entry-points tetap dikonfigurasi lewat file.
|
||||
|
||||
Penyimpanan menulis section `channels.<name>` di `~/.vibe-trading/agent.json` secara atomik. File konfigurasi YAML bersifat read-only bagi Web UI (edit dari browser memerlukan JSON), dan panel menyatakannya alih-alih gagal. Channel tanpa panduan khusus menampilkan formulir generik yang diturunkan dari default-nya.
|
||||
|
||||
|
||||
+1
-1
@@ -1050,7 +1050,7 @@ Built-in adapters は `websocket`、`telegram`、`slack`、`discord`、`matrix`
|
||||
|
||||
コマンドは大文字小文字を区別せず、メッセージ全体として送信する必要があります(例:`hello /new` はリセットではなく通常メッセージとして処理されます)。
|
||||
|
||||
**Web UI から設定**: Settings ページの **IM Channels** パネルでは、ファイルを手編集せずにチャンネルを設定できます。チャンネルを展開すると設定パネルが開きます。フィールドはバックエンドのメタデータから描画され、secret の値はブラウザに返されず、マスク表示(`****` +末尾 4 文字)のみです。DingTalk が最初の完全なガイド付きチャンネルです:[open-dev.dingtalk.com](https://open-dev.dingtalk.com/) でアプリを作成し、ボット機能を追加して Stream Mode を有効化(公開コールバック URL 不要)、AppKey を Client ID、AppSecret を Client Secret にコピーしてアプリを公開します。QQ も完全ガイド付きチャンネルになりました:QQ公式プラットフォーム([q.qq.com](https://q.qq.com/))でボットを登録し、AppID と AppSecret をフォームにコピーし、保存前に **Test connection** で検証してから有効化します(公開コールバック URL 不要、公式 botpy SDK による WebSocket 接続)。Email と WebSocket も完全ガイド付きチャンネルとして加わりました:Email の **Test connection** は何も送信せずに、IMAP ログイン・メールボックスフォルダ・SMTP ログインをプロバイダーに対して検証し、一方 WebSocket は(リモートサービスではなくローカルサーバーであるため)、このマシン上の TLS 証明書/鍵マテリアルとアドレスの使用可否を検証します。稼働中のサーバーがすでに確保しているアドレスは、失敗ではなく想定された状態として扱われます。WebSocket 設定を保存するとサーバーがホットスワップされるため、接続中のクライアント(Web UI のチャットを含む)は一時切断され、再接続します。**Test connection** は保存前にフォームの入力値だけで接続を試み、正直な結果コード(`ok` / `invalid_credentials` / `network` / `unsupported`)を返します。**Enable** は即適用:稼働中の channel runtime はプロセス再起動なしに該当アダプターのみをホットスワップし、有効化時に認証情報を自動検証します(検証失敗後に明示的に **Enable anyway** を選んだ場合のみスキップ)。無効化しても保存済み認証情報は残るため、再有効化時に再入力は不要です。ここには組み込みアダプターのみが表示されます。entry points 経由のプラグインチャンネルは引き続きファイルで設定します。
|
||||
**Web UI から設定**: Settings ページの **IM Channels** パネルでは、ファイルを手編集せずにチャンネルを設定できます。チャンネルを展開すると設定パネルが開きます。フィールドはバックエンドのメタデータから描画され、secret の値はブラウザに返されず、マスク表示(`****` +末尾 4 文字)のみです。DingTalk が最初の完全なガイド付きチャンネルです:[open-dev.dingtalk.com](https://open-dev.dingtalk.com/) でアプリを作成し、ボット機能を追加して Stream Mode を有効化(公開コールバック URL 不要)、AppKey を Client ID、AppSecret を Client Secret にコピーしてアプリを公開します。QQ も完全ガイド付きチャンネルになりました:QQ公式プラットフォーム([q.qq.com](https://q.qq.com/))でボットを登録し、AppID と AppSecret をフォームにコピーし、保存前に **Test connection** で検証してから有効化します(公開コールバック URL 不要、公式 botpy SDK による WebSocket 接続)。Email と WebSocket も完全ガイド付きチャンネルとして加わりました:Email の **Test connection** は何も送信せずに、IMAP ログイン・メールボックスフォルダ・SMTP ログインをプロバイダーに対して検証し、一方 WebSocket は(リモートサービスではなくローカルサーバーであるため)、このマシン上の TLS 証明書/鍵マテリアルとアドレスの使用可否を検証します。稼働中のサーバーがすでに確保しているアドレスは、失敗ではなく想定された状態として扱われます。WebSocket 設定を保存するとサーバーがホットスワップされるため、接続中のクライアント(Web UI のチャットを含む)は一時切断され、再接続します。Feishu も完全ガイド付きチャンネルになりました:Feishu オープンプラットフォーム([open.feishu.cn](https://open.feishu.cn/)、国際版 Lark は [open.larksuite.com](https://open.larksuite.com/))でカスタムアプリを作成し、ボット機能を追加して、フォームで対応するドメインを選択し、App ID と App Secret をコピーし、保存前に **Test connection** で検証してから有効化します(公開コールバック URL 不要、公式 lark-oapi SDK による WebSocket 長時間接続)。**Test connection** は保存前にフォームの入力値だけで接続を試み、正直な結果コード(`ok` / `invalid_credentials` / `network` / `unsupported`)を返します。**Enable** は即適用:稼働中の channel runtime はプロセス再起動なしに該当アダプターのみをホットスワップし、有効化時に認証情報を自動検証します(検証失敗後に明示的に **Enable anyway** を選んだ場合のみスキップ)。無効化しても保存済み認証情報は残るため、再有効化時に再入力は不要です。ここには組み込みアダプターのみが表示されます。entry points 経由のプラグインチャンネルは引き続きファイルで設定します。
|
||||
|
||||
保存は `~/.vibe-trading/agent.json` の `channels.<name>` をアトミックに更新します。YAML 設定ファイルは Web UI から読み取り専用です(ブラウザ編集には JSON が必要で、パネルはエラーの代わりにその旨を表示します)。専用ガイドのないチャンネルはデフォルト設定から生成された汎用フォームを描画します。
|
||||
|
||||
|
||||
+1
-1
@@ -1051,7 +1051,7 @@ Built-in adapters는 `websocket`, `telegram`, `slack`, `discord`, `matrix`, `wha
|
||||
|
||||
명령어는 대소문자를 구분하지 않으며, 전체 메시지로 전송해야 합니다 (예: `hello /new`은 초기화가 아닌 일반 메시지로 처리됩니다).
|
||||
|
||||
**Web UI에서 설정**: Settings 페이지의 **IM Channels** 패널에서는 파일을 직접 편집하지 않고도 채널을 설정할 수 있습니다. 채널을 펼치면 설정 패널이 열립니다. 필드는 백엔드 메타데이터에서 렌더링되고, secret 값은 브라우저에 절대 반환되지 않으며 마스킹된 형태(`****` + 마지막 4자리)로만 표시됩니다. DingTalk이 첫 번째로 완전한 가이드를 제공하는 채널입니다: [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)에서 앱 생성 → 봇 기능 추가 후 Stream Mode 활성화(공개 콜백 URL 불필요) → AppKey를 Client ID에, AppSecret을 Client Secret에 복사 → 앱 게시. QQ도 이제 완전한 가이드를 제공하는 채널입니다: QQ 오픈 플랫폼([q.qq.com](https://q.qq.com/))에서 봇을 등록하고 AppID와 AppSecret을 폼에 입력한 뒤, 저장 전에 **Test connection**으로 검증하고 활성화하면 됩니다(공개 콜백 URL 불필요, 공식 botpy SDK를 통한 WebSocket 연결). Email과 WebSocket도 이제 완전한 가이드를 제공하는 채널입니다: Email의 **Test connection**은 아무것도 전송하지 않고 IMAP 로그인, 메일함 폴더, SMTP 로그인을 서비스 공급자에 대해 시도하며, WebSocket은 원격 서비스가 아닌 로컬 서버이므로 이 머신에서 TLS 인증서/키 소재와 주소 사용 가능성을 검증합니다 — 이미 실행 중인 서버가 점유한 주소는 실패가 아니라 예상된 상태로 표시됩니다. WebSocket 설정을 저장하면 서버가 핫 스왑되므로, 연결된 클라이언트(Web UI 채팅 포함)가 잠시 끊겼다가 다시 연결됩니다. **Test connection**은 저장 전에 폼의 현재 입력값만으로 연결을 시도하고 정확한 결과 코드(`ok`, `invalid_credentials`, `network`, `unsupported`)를 반환합니다. **Enable**은 즉시 적용됩니다: 실행 중인 채널 런타임은 프로세스 재시작 없이 해당 어댑터만 핫 스왑하며, 활성화 시 자격 증명을 자동 검증합니다(검증 실패 후 명시적으로 **Enable anyway**를 선택한 경우에만 스킵). 비활성화해도 저장된 자격 증명은 유지되므로 재활성화 시 재입력이 필요 없습니다. 여기에는 내장 어댑터만 표시되며, entry points로 추가된 플러그인 채널은 계속 파일에서 설정합니다.
|
||||
**Web UI에서 설정**: Settings 페이지의 **IM Channels** 패널에서는 파일을 직접 편집하지 않고도 채널을 설정할 수 있습니다. 채널을 펼치면 설정 패널이 열립니다. 필드는 백엔드 메타데이터에서 렌더링되고, secret 값은 브라우저에 절대 반환되지 않으며 마스킹된 형태(`****` + 마지막 4자리)로만 표시됩니다. DingTalk이 첫 번째로 완전한 가이드를 제공하는 채널입니다: [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)에서 앱 생성 → 봇 기능 추가 후 Stream Mode 활성화(공개 콜백 URL 불필요) → AppKey를 Client ID에, AppSecret을 Client Secret에 복사 → 앱 게시. QQ도 이제 완전한 가이드를 제공하는 채널입니다: QQ 오픈 플랫폼([q.qq.com](https://q.qq.com/))에서 봇을 등록하고 AppID와 AppSecret을 폼에 입력한 뒤, 저장 전에 **Test connection**으로 검증하고 활성화하면 됩니다(공개 콜백 URL 불필요, 공식 botpy SDK를 통한 WebSocket 연결). Email과 WebSocket도 이제 완전한 가이드를 제공하는 채널입니다: Email의 **Test connection**은 아무것도 전송하지 않고 IMAP 로그인, 메일함 폴더, SMTP 로그인을 서비스 공급자에 대해 시도하며, WebSocket은 원격 서비스가 아닌 로컬 서버이므로 이 머신에서 TLS 인증서/키 소재와 주소 사용 가능성을 검증합니다 — 이미 실행 중인 서버가 점유한 주소는 실패가 아니라 예상된 상태로 표시됩니다. WebSocket 설정을 저장하면 서버가 핫 스왑되므로, 연결된 클라이언트(Web UI 채팅 포함)가 잠시 끊겼다가 다시 연결됩니다. Feishu도 이제 완전한 가이드를 제공하는 채널입니다: Feishu 오픈 플랫폼([open.feishu.cn](https://open.feishu.cn/), 국제 버전 Lark는 [open.larksuite.com](https://open.larksuite.com/))에서 자체 개발 앱을 생성하고 봇 기능을 추가한 뒤, 폼에서 해당하는 도메인을 선택하고 App ID와 App Secret을 입력한 다음, 저장 전에 **Test connection**으로 검증하고 활성화하면 됩니다(공개 콜백 URL 불필요, 공식 lark-oapi SDK를 통한 WebSocket 상시 연결). **Test connection**은 저장 전에 폼의 현재 입력값만으로 연결을 시도하고 정확한 결과 코드(`ok`, `invalid_credentials`, `network`, `unsupported`)를 반환합니다. **Enable**은 즉시 적용됩니다: 실행 중인 채널 런타임은 프로세스 재시작 없이 해당 어댑터만 핫 스왑하며, 활성화 시 자격 증명을 자동 검증합니다(검증 실패 후 명시적으로 **Enable anyway**를 선택한 경우에만 스킵). 비활성화해도 저장된 자격 증명은 유지되므로 재활성화 시 재입력이 필요 없습니다. 여기에는 내장 어댑터만 표시되며, entry points로 추가된 플러그인 채널은 계속 파일에서 설정합니다.
|
||||
|
||||
저장은 `~/.vibe-trading/agent.json`의 `channels.<name>` 섹션을 원자적으로 업데이트합니다. YAML 설정 파일은 Web UI에서 읽기 전용입니다(브라우저 편집에는 JSON이 필요하며, 패널은 오류 대신 그 사실을 표시합니다). 전용 가이드가 없는 채널은 기본 설정에서 파생된 일반 폼을 렌더링합니다.
|
||||
|
||||
|
||||
+1
-1
@@ -1055,7 +1055,7 @@ vibe-trading channels pairing --channel telegram list
|
||||
|
||||
命令不区分大小写,且必须作为整条消息发送(例如 `hello /new` 会被当作普通消息而非重置命令)。
|
||||
|
||||
**从 Web UI 配置**:Settings 页面的 **IM Channels** 面板可以直接在网页上完成通道配置,无需手工编辑文件。展开某个通道即打开配置面板:表单字段由后端元数据渲染;secret 值不会回传给浏览器,只显示掩码(`****` 加末 4 位)。钉钉是第一个带完整接入指南的通道:在 [open-dev.dingtalk.com](https://open-dev.dingtalk.com/) 创建应用,添加机器人能力并开启 Stream Mode(无需公网回调地址),把 AppKey 填入 Client ID、AppSecret 填入 Client Secret,然后发布应用。QQ 也已加入带完整接入指南的通道:在 QQ 开放平台([q.qq.com](https://q.qq.com/))注册机器人,把 AppID 与 AppSecret 填入表单,保存前先用 **Test connection** 验证,再启用即可(无需公网回调地址,通过官方 botpy SDK 建立 WebSocket 连接)。Email 与 WebSocket 也已加入带完整接入指南的通道:Email 的 **Test connection** 会在不发送任何内容的前提下,向你的服务商探测 IMAP 登录、邮箱文件夹与 SMTP 登录;而 WebSocket——本机服务器而非远程服务——校验的是本机的 TLS 证书/密钥材料与地址可用性,地址若已被正在运行的服务器占用,会被视为预期状态而非失败。保存 WebSocket 配置会热替换服务器,因此已连接的客户端(包括 Web UI 聊天)会短暂断开并重新连接。**Test connection** 在任何内容保存之前就用表单中的当前值发起探测,并返回真实结果码(`ok`、`invalid_credentials`、`network`、`unsupported`)。打开 **Enable** 立即生效:运行中的通道 runtime 只热替换该通道的适配器,无需重启进程;启用时会自动校验凭据,除非你在校验失败后显式选择 **Enable anyway** 跳过。停用通道不会删除已保存的凭据,重新启用无需再次录入。此处仅列出内置适配器;通过 entry points 接入的插件频道仍需编辑配置文件。
|
||||
**从 Web UI 配置**:Settings 页面的 **IM Channels** 面板可以直接在网页上完成通道配置,无需手工编辑文件。展开某个通道即打开配置面板:表单字段由后端元数据渲染;secret 值不会回传给浏览器,只显示掩码(`****` 加末 4 位)。钉钉是第一个带完整接入指南的通道:在 [open-dev.dingtalk.com](https://open-dev.dingtalk.com/) 创建应用,添加机器人能力并开启 Stream Mode(无需公网回调地址),把 AppKey 填入 Client ID、AppSecret 填入 Client Secret,然后发布应用。QQ 也已加入带完整接入指南的通道:在 QQ 开放平台([q.qq.com](https://q.qq.com/))注册机器人,把 AppID 与 AppSecret 填入表单,保存前先用 **Test connection** 验证,再启用即可(无需公网回调地址,通过官方 botpy SDK 建立 WebSocket 连接)。Email 与 WebSocket 也已加入带完整接入指南的通道:Email 的 **Test connection** 会在不发送任何内容的前提下,向你的服务商探测 IMAP 登录、邮箱文件夹与 SMTP 登录;而 WebSocket——本机服务器而非远程服务——校验的是本机的 TLS 证书/密钥材料与地址可用性,地址若已被正在运行的服务器占用,会被视为预期状态而非失败。保存 WebSocket 配置会热替换服务器,因此已连接的客户端(包括 Web UI 聊天)会短暂断开并重新连接。飞书也已加入带完整接入指南的通道:在飞书开放平台([open.feishu.cn](https://open.feishu.cn/),国际版 Lark 为 [open.larksuite.com](https://open.larksuite.com/))创建企业自建应用,添加机器人能力,在表单中选择对应域名,把 App ID 与 App Secret 填入,保存前先用 **Test connection** 验证,再启用即可(无需公网回调地址,通过官方 lark-oapi SDK 建立 WebSocket 长连接)。**Test connection** 在任何内容保存之前就用表单中的当前值发起探测,并返回真实结果码(`ok`、`invalid_credentials`、`network`、`unsupported`)。打开 **Enable** 立即生效:运行中的通道 runtime 只热替换该通道的适配器,无需重启进程;启用时会自动校验凭据,除非你在校验失败后显式选择 **Enable anyway** 跳过。停用通道不会删除已保存的凭据,重新启用无需再次录入。此处仅列出内置适配器;通过 entry points 接入的插件频道仍需编辑配置文件。
|
||||
|
||||
保存会原子地更新 `~/.vibe-trading/agent.json` 的 `channels.<name>` 段。YAML 配置文件对 Web UI 只读(浏览器内编辑要求 JSON),面板会直接说明这一点而不是报错。没有专属指南的通道会按其默认配置渲染通用表单。
|
||||
|
||||
|
||||
@@ -86,6 +86,41 @@ def _dingtalk_hints() -> list[FieldHint]:
|
||||
]
|
||||
|
||||
|
||||
def _feishu_hints() -> list[FieldHint]:
|
||||
"""Return the hand-written Feishu field hints (``enabled`` excluded).
|
||||
|
||||
``app_id``/``app_secret`` are required because
|
||||
:meth:`FeishuChannel.start` refuses to connect without them; the
|
||||
``encrypt_key``/``verification_token`` pair stays optional because the
|
||||
WebSocket long-connection mode works without them for most apps.
|
||||
"""
|
||||
specs = (
|
||||
("app_id", "text", False, True),
|
||||
("app_secret", "password", True, True),
|
||||
("encrypt_key", "password", True, False),
|
||||
("verification_token", "password", True, False),
|
||||
("allow_from", "list", False, False),
|
||||
("react_emoji", "text", False, False),
|
||||
("done_emoji", "text", False, False),
|
||||
("tool_hint_prefix", "text", False, False),
|
||||
("group_policy", "text", False, False),
|
||||
("reply_to_message", "bool", False, False),
|
||||
("streaming", "bool", False, False),
|
||||
("domain", "text", False, False),
|
||||
("topic_isolation", "bool", False, False),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"key": key,
|
||||
"type": widget,
|
||||
"secret": secret,
|
||||
"required": required,
|
||||
"help_key": f"{_HELP_KEY_PREFIX}.feishu.{key}",
|
||||
}
|
||||
for key, widget, secret, required in specs
|
||||
]
|
||||
|
||||
|
||||
def _qq_hints() -> list[FieldHint]:
|
||||
"""Return the hand-written QQ field hints (``enabled`` excluded)."""
|
||||
specs = (
|
||||
@@ -206,6 +241,7 @@ def _websocket_hints() -> list[FieldHint]:
|
||||
FIELD_HINTS: dict[str, list[FieldHint]] = {
|
||||
"dingtalk": _dingtalk_hints(),
|
||||
"email": _email_hints(),
|
||||
"feishu": _feishu_hints(),
|
||||
"qq": _qq_hints(),
|
||||
"websocket": _websocket_hints(),
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ from rich.text import Text
|
||||
|
||||
from src.channels.bus.events import DeliveryReceipt, OutboundMessage
|
||||
from src.channels.bus.queue import MessageBus
|
||||
from src.channels import feishu_probe
|
||||
from src.channels.base import BaseChannel
|
||||
from src.channels.utils import get_media_dir
|
||||
from pydantic import BaseModel
|
||||
@@ -614,8 +615,10 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
name = "feishu"
|
||||
display_name = "Feishu"
|
||||
supports_connection_test = True
|
||||
|
||||
_STREAM_EDIT_INTERVAL = 0.5 # throttle between CardKit streaming updates
|
||||
_WS_CLOSE_TIMEOUT_S = 3.0 # bound for the best-effort socket close in stop()
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
@@ -629,6 +632,7 @@ class FeishuChannel(BaseChannel):
|
||||
self._client: Any = None
|
||||
self._ws_client: Any = None
|
||||
self._ws_thread: threading.Thread | None = None
|
||||
self._ws_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
||||
@@ -791,6 +795,7 @@ class FeishuChannel(BaseChannel):
|
||||
asyncio.set_event_loop(ws_loop)
|
||||
# Patch the module-level loop used by lark's ws Client.start()
|
||||
_lark_ws_client.loop = ws_loop
|
||||
self._ws_loop = ws_loop
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
@@ -800,6 +805,7 @@ class FeishuChannel(BaseChannel):
|
||||
if self._running:
|
||||
time.sleep(5)
|
||||
finally:
|
||||
self._ws_loop = None
|
||||
if getattr(_lark_ws_client, "loop", None) is ws_loop:
|
||||
_lark_ws_client.loop = previous_loop
|
||||
with suppress(Exception):
|
||||
@@ -826,16 +832,47 @@ class FeishuChannel(BaseChannel):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""
|
||||
Stop the Feishu bot.
|
||||
"""Stop the Feishu bot and terminate the lark WebSocket thread.
|
||||
|
||||
Notice: lark.ws.Client does not expose stop method, simply exiting the program will close the client.
|
||||
|
||||
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
|
||||
``lark.ws.Client`` exposes no stop method (see
|
||||
https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86),
|
||||
and its ``start()`` blocks inside ``run_until_complete`` on the
|
||||
dedicated loop created by ``run_ws`` while the SDK receive loop runs
|
||||
with ``auto_reconnect=True``. Merely clearing ``_running`` therefore
|
||||
left a zombie WebSocket after a hot swap, with the old and the
|
||||
replacement adapter both answering events. Instead, best-effort close
|
||||
the SDK connection so no socket lingers, then stop the dedicated loop:
|
||||
that unwinds ``run_until_complete``, kills the receive/ping/reconnect
|
||||
tasks so auto-reconnect cannot resurrect them, and the thread exits
|
||||
because ``_running`` is already False. Idempotent, and safe when the
|
||||
channel was never started.
|
||||
"""
|
||||
self._running = False
|
||||
ws_loop = self._ws_loop
|
||||
if ws_loop is not None and ws_loop.is_running():
|
||||
conn = getattr(self._ws_client, "_conn", None)
|
||||
if conn is not None:
|
||||
with suppress(Exception):
|
||||
await asyncio.wait_for(
|
||||
asyncio.wrap_future(
|
||||
asyncio.run_coroutine_threadsafe(conn.close(), ws_loop)
|
||||
),
|
||||
timeout=self._WS_CLOSE_TIMEOUT_S,
|
||||
)
|
||||
with suppress(Exception):
|
||||
ws_loop.call_soon_threadsafe(ws_loop.stop)
|
||||
self.logger.info("bot stopped")
|
||||
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
"""Validate the Feishu credentials with a standalone token request.
|
||||
|
||||
Delegates to :func:`src.channels.feishu_probe.test_connection`; see
|
||||
that function for the full contract (codes, scrubbing, token discard).
|
||||
"""
|
||||
return await feishu_probe.test_connection(
|
||||
self.config, sdk_available=FEISHU_AVAILABLE
|
||||
)
|
||||
|
||||
def _fetch_bot_open_id(self) -> str | None:
|
||||
"""Fetch the bot's own open_id via GET /open-apis/bot/v3/info."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Standalone Feishu/Lark credential probe, mirroring ``qq_probe.py``.
|
||||
|
||||
The probe validates an (possibly unsaved) credential set against the
|
||||
``tenant_access_token`` endpoint without touching the channel's lark SDK
|
||||
client. Everything here is stateless: functions take the ``FeishuConfig``
|
||||
explicitly so the module never imports the adapter at runtime (only under
|
||||
``TYPE_CHECKING``) — ``feishu.py`` guards the heavy ``lark_oapi`` SDK behind
|
||||
``FEISHU_AVAILABLE``, and a credential check never needs it. The
|
||||
request/classification logic lives in :mod:`src.channels.token_probe`, the
|
||||
shared building block for token-endpoint probes.
|
||||
|
||||
Note that Feishu may answer HTTP 200 with an error body (e.g.
|
||||
``{"code": 10014, "msg": "app secret invalid"}``) for bad credentials; the
|
||||
shared "200 but no token" branch already classifies that as
|
||||
``invalid_credentials``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from src.channels.token_probe import probe_token_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.channels.feishu import FeishuConfig
|
||||
|
||||
FEISHU_TENANT_TOKEN_URL = (
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
)
|
||||
LARK_TENANT_TOKEN_URL = (
|
||||
"https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"
|
||||
)
|
||||
|
||||
|
||||
async def test_connection(
|
||||
config: FeishuConfig, *, sdk_available: bool
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the Feishu/Lark credentials with a standalone token request.
|
||||
|
||||
Uses a fresh ``httpx.AsyncClient`` rather than any lark SDK client (which
|
||||
only exists after :meth:`FeishuChannel.start`), so an unsaved credential
|
||||
set can be checked before the channel is started. A successful tenant
|
||||
access token is discarded: it is never returned, logged, or cached.
|
||||
|
||||
Args:
|
||||
config: The Feishu credential set to validate.
|
||||
sdk_available: Whether the optional ``lark-oapi`` SDK imported.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable envelope with ``ok`` and a ``code`` of ``ok`` /
|
||||
``invalid_credentials`` / ``network``, plus an ``sdk_available``
|
||||
flag. Any ``detail`` is scrubbed of credential values.
|
||||
"""
|
||||
if not config.app_id or not config.app_secret:
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "invalid_credentials",
|
||||
"detail": "missing credentials",
|
||||
"sdk_available": sdk_available,
|
||||
}
|
||||
|
||||
url = LARK_TENANT_TOKEN_URL if config.domain == "lark" else FEISHU_TENANT_TOKEN_URL
|
||||
return await probe_token_endpoint(
|
||||
url=url,
|
||||
payload={"app_id": config.app_id, "app_secret": config.app_secret},
|
||||
token_key="tenant_access_token",
|
||||
secrets=(config.app_secret, config.app_id),
|
||||
sdk_available=sdk_available,
|
||||
)
|
||||
@@ -28,6 +28,7 @@ _INTERNAL = frozenset(
|
||||
"dingtalk_media",
|
||||
"dingtalk_probe",
|
||||
"email_probe",
|
||||
"feishu_probe",
|
||||
"manager",
|
||||
"pairing",
|
||||
"qq_probe",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for channel config field metadata and fail-safe secret masking.
|
||||
|
||||
Covers the uiHints registry (hand-written for DingTalk and QQ, derived
|
||||
elsewhere) and the security acceptance criterion: no key matching
|
||||
Covers the uiHints registry (hand-written for DingTalk, Email, Feishu, QQ
|
||||
and WebSocket, derived elsewhere) and the security acceptance criterion: no key matching
|
||||
:data:`SECRET_KEY_RE` ever survives in the non-secret ``values`` half.
|
||||
"""
|
||||
|
||||
@@ -176,6 +176,105 @@ def test_qq_field_hints_exact_snapshot() -> None:
|
||||
assert "enabled" not in {hint["key"] for hint in hints}
|
||||
|
||||
|
||||
def test_feishu_field_hints_exact_snapshot() -> None:
|
||||
"""Feishu hints match the frozen contract, with ``enabled`` excluded."""
|
||||
hints = channel_field_hints("feishu")
|
||||
assert hints == [
|
||||
{
|
||||
"key": "app_id",
|
||||
"type": "text",
|
||||
"secret": False,
|
||||
"required": True,
|
||||
"help_key": "settings.channels.fields.feishu.app_id",
|
||||
},
|
||||
{
|
||||
"key": "app_secret",
|
||||
"type": "password",
|
||||
"secret": True,
|
||||
"required": True,
|
||||
"help_key": "settings.channels.fields.feishu.app_secret",
|
||||
},
|
||||
{
|
||||
"key": "encrypt_key",
|
||||
"type": "password",
|
||||
"secret": True,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.encrypt_key",
|
||||
},
|
||||
{
|
||||
"key": "verification_token",
|
||||
"type": "password",
|
||||
"secret": True,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.verification_token",
|
||||
},
|
||||
{
|
||||
"key": "allow_from",
|
||||
"type": "list",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.allow_from",
|
||||
},
|
||||
{
|
||||
"key": "react_emoji",
|
||||
"type": "text",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.react_emoji",
|
||||
},
|
||||
{
|
||||
"key": "done_emoji",
|
||||
"type": "text",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.done_emoji",
|
||||
},
|
||||
{
|
||||
"key": "tool_hint_prefix",
|
||||
"type": "text",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.tool_hint_prefix",
|
||||
},
|
||||
{
|
||||
"key": "group_policy",
|
||||
"type": "text",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.group_policy",
|
||||
},
|
||||
{
|
||||
"key": "reply_to_message",
|
||||
"type": "bool",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.reply_to_message",
|
||||
},
|
||||
{
|
||||
"key": "streaming",
|
||||
"type": "bool",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.streaming",
|
||||
},
|
||||
{
|
||||
"key": "domain",
|
||||
"type": "text",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.domain",
|
||||
},
|
||||
{
|
||||
"key": "topic_isolation",
|
||||
"type": "bool",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
"help_key": "settings.channels.fields.feishu.topic_isolation",
|
||||
},
|
||||
]
|
||||
assert "enabled" not in {hint["key"] for hint in hints}
|
||||
|
||||
|
||||
# --- (a2) hand-written Email / WebSocket hints ------------------------------- #
|
||||
|
||||
_EMAIL_HINT_KEYS = (
|
||||
@@ -528,6 +627,29 @@ def test_hint_marked_secret_is_masked_for_known_channel() -> None:
|
||||
assert secrets == {"client_secret": {"set": True, "masked": "****1234"}}
|
||||
|
||||
|
||||
def test_feishu_secrets_are_masked_and_behavior_values_stay_visible() -> None:
|
||||
"""All three Feishu credential fields mask; behavior fields stay in values."""
|
||||
section = {
|
||||
"enabled": True,
|
||||
"app_id": "cli_app_id_123",
|
||||
"app_secret": "dummy-secret-1234",
|
||||
"encrypt_key": "dummy-encrypt-5678",
|
||||
"verification_token": "dummy-verify-9012",
|
||||
"react_emoji": "THUMBSUP",
|
||||
"domain": "lark",
|
||||
"group_policy": "open",
|
||||
}
|
||||
values, secrets = split_values_secrets("feishu", section)
|
||||
assert set(secrets) == {"app_secret", "encrypt_key", "verification_token"}
|
||||
assert secrets["app_secret"] == {"set": True, "masked": "****1234"}
|
||||
assert secrets["encrypt_key"] == {"set": True, "masked": "****5678"}
|
||||
assert secrets["verification_token"] == {"set": True, "masked": "****9012"}
|
||||
assert values["react_emoji"] == "THUMBSUP"
|
||||
assert values["domain"] == "lark"
|
||||
assert values["group_policy"] == "open"
|
||||
assert values["app_id"] == "cli_app_id_123"
|
||||
|
||||
|
||||
def test_secret_key_regex_is_case_insensitive() -> None:
|
||||
"""Credential-shaped keys match case-insensitively; identifiers do not."""
|
||||
for key in (
|
||||
|
||||
@@ -34,6 +34,8 @@ EMAIL_IMAP_PASSWORD = "email-imap-password-987654321"
|
||||
EMAIL_SMTP_PASSWORD = "email-smtp-password-123456789"
|
||||
WS_TOKEN = "ws-token-secret-abcdefghij"
|
||||
WS_ISSUE_SECRET = "ws-issue-secret-987654321"
|
||||
FEISHU_APP_ID = "cli_feishu_app_id_1234567890"
|
||||
FEISHU_STORED_SECRET = "feishu-stored-secret-abcdefghij"
|
||||
|
||||
# Captured before any test monkeypatches httpx, so repeated injections in a
|
||||
# single test still wrap the real client (test_dingtalk_connection_test idiom).
|
||||
@@ -153,6 +155,16 @@ def _email_section(**overrides: Any) -> dict[str, Any]:
|
||||
return section
|
||||
|
||||
|
||||
def _feishu_section(**overrides: Any) -> dict[str, Any]:
|
||||
section: dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"app_id": FEISHU_APP_ID,
|
||||
"app_secret": FEISHU_STORED_SECRET,
|
||||
}
|
||||
section.update(overrides)
|
||||
return section
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Return a loopback port the OS just handed out (free at bind time)."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
@@ -219,6 +231,30 @@ def test_get_masks_secrets_and_reports_writable(tmp_path: Path, monkeypatch) ->
|
||||
assert api_server._channel_runtime is None
|
||||
|
||||
|
||||
def test_get_feishu_masks_secrets_and_reports_test_support(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
client, _ = _client(tmp_path, monkeypatch, channels={"feishu": _feishu_section()})
|
||||
|
||||
response = client.get("/channels/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
entry = response.json()["channels"]["feishu"]
|
||||
assert entry["display_name"] == "Feishu"
|
||||
assert entry["supports_test"] is True
|
||||
assert entry["values"]["app_id"] == FEISHU_APP_ID
|
||||
assert entry["values"]["enabled"] is False
|
||||
assert "app_secret" not in entry["values"]
|
||||
assert entry["secrets"]["app_secret"] == {"set": True, "masked": "****ghij"}
|
||||
keys = [field["key"] for field in entry["fields"]]
|
||||
assert keys[:2] == ["app_id", "app_secret"]
|
||||
assert "enabled" not in keys
|
||||
app_id_hint = next(f for f in entry["fields"] if f["key"] == "app_id")
|
||||
assert app_id_hint["help_key"] == "settings.channels.fields.feishu.app_id"
|
||||
assert app_id_hint["secret"] is False
|
||||
assert FEISHU_STORED_SECRET not in response.text
|
||||
|
||||
|
||||
def test_get_excludes_non_channel_helper_modules(tmp_path: Path, monkeypatch) -> None:
|
||||
client, _ = _client(tmp_path, monkeypatch)
|
||||
|
||||
@@ -325,6 +361,47 @@ def test_pristine_form_roundtrip_validates_for_typed_fields(
|
||||
assert "form-typed-secret-xyz987654321" not in response.text
|
||||
|
||||
|
||||
def test_feishu_pristine_form_roundtrip_validates_for_typed_fields(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Same roundtrip for Feishu's typed fields (Literal domain/group_policy,
|
||||
``None``-default done_emoji, bools) through the ``tenant_access_token`` probe."""
|
||||
client, _ = _client(tmp_path, monkeypatch, channels={})
|
||||
entry = client.get("/channels/config").json()["channels"]["feishu"]
|
||||
|
||||
patch: dict[str, Any] = {}
|
||||
for field in entry["fields"]:
|
||||
if field["secret"]:
|
||||
continue
|
||||
value = entry["values"].get(field["key"])
|
||||
if field["type"] == "list":
|
||||
patch[field["key"]] = value if isinstance(value, list) else []
|
||||
elif field["type"] == "bool":
|
||||
patch[field["key"]] = bool(value)
|
||||
else:
|
||||
patch[field["key"]] = "" if value is None else str(value)
|
||||
patch["app_id"] = "cli_round_trip_app"
|
||||
patch["app_secret"] = "form-typed-feishu-secret-xyz987654321"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert "open.feishu.cn" in str(request.url)
|
||||
return httpx.Response(
|
||||
200, json={"tenant_access_token": "probe-token-do-not-leak"}
|
||||
)
|
||||
|
||||
requests = _inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
response = client.post("/channels/feishu/test", json={"config": patch})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["ok"] is True, body
|
||||
assert body["code"] == "ok"
|
||||
assert len(requests) == 1
|
||||
assert "form-typed-feishu-secret-xyz987654321" not in response.text
|
||||
assert "probe-token-do-not-leak" not in response.text
|
||||
|
||||
|
||||
def test_display_config_path_relativizes_home() -> None:
|
||||
home = Path.home()
|
||||
|
||||
@@ -405,6 +482,54 @@ def test_put_enable_with_bad_credentials_is_blocked_before_write(
|
||||
assert STORED_SECRET not in response.text
|
||||
|
||||
|
||||
def test_put_feishu_merge_patch_writes_section(tmp_path: Path, monkeypatch) -> None:
|
||||
client, path = _client(
|
||||
tmp_path, monkeypatch, channels={"feishu": _feishu_section()}
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
"/channels/config/feishu",
|
||||
json={"config": {"domain": "lark", "react_emoji": "OK"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["applied"] == "deferred"
|
||||
on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["feishu"]
|
||||
assert on_disk["domain"] == "lark"
|
||||
assert on_disk["react_emoji"] == "OK"
|
||||
# A secret absent from the patch keeps its stored value.
|
||||
assert on_disk["app_secret"] == FEISHU_STORED_SECRET
|
||||
assert FEISHU_STORED_SECRET not in response.text
|
||||
|
||||
|
||||
def test_put_feishu_enable_with_bad_credentials_is_blocked_before_write(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Feishu rejects bad credentials with HTTP 200 + an error body; the
|
||||
enable-transition probe must still block the write with 422."""
|
||||
client, path = _client(
|
||||
tmp_path, monkeypatch, channels={"feishu": _feishu_section()}
|
||||
)
|
||||
before = path.read_bytes()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"code": 10014, "msg": "app secret invalid"})
|
||||
|
||||
requests = _inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
response = client.put("/channels/config/feishu", json={"config": {"enabled": True}})
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "invalid_credentials"
|
||||
assert detail["fields"] == []
|
||||
assert len(requests) == 1
|
||||
assert "open.feishu.cn" in str(requests[0].url)
|
||||
assert path.read_bytes() == before
|
||||
assert api_server._channel_runtime is None
|
||||
assert FEISHU_STORED_SECRET not in response.text
|
||||
|
||||
|
||||
def test_put_enable_rejection_carries_scrubbed_message(tmp_path: Path, monkeypatch) -> None:
|
||||
client, path = _client(
|
||||
tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}
|
||||
|
||||
@@ -39,7 +39,6 @@ _DEFAULT_CHANNEL_CANDIDATES: list[tuple[str, str]] = [
|
||||
("src.channels.discord", "DiscordChannel"),
|
||||
("src.channels.telegram", "TelegramChannel"),
|
||||
("src.channels.slack", "SlackChannel"),
|
||||
("src.channels.feishu", "FeishuChannel"),
|
||||
("src.channels.wecom", "WecomChannel"),
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Connection-test contract for the Feishu/Lark channel.
|
||||
|
||||
Mirrors ``test_qq_connection_test.py``: Feishu implements the standalone
|
||||
credential probe against the ``tenant_access_token`` endpoint and never
|
||||
touches the lark SDK client (which only exists after ``start()``). The
|
||||
contract codes the frontend dispatches on are ``ok | invalid_credentials |
|
||||
network`` plus an ``sdk_available`` flag, and no credential or token value may
|
||||
ever leak into the result or logs. Feishu-specific: the endpoint may answer
|
||||
HTTP 200 with an error body (``{"code": 10014, ...}``) for bad credentials,
|
||||
which the shared "200 but no token" branch classifies as
|
||||
``invalid_credentials``, and ``domain="lark"`` switches the probe to the
|
||||
Lark Suite host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.channels.bus.queue import MessageBus
|
||||
from src.channels.feishu import FEISHU_AVAILABLE, FeishuChannel
|
||||
|
||||
FEISHU_TOKEN_URL = (
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
)
|
||||
LARK_TOKEN_URL = (
|
||||
"https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"
|
||||
)
|
||||
APP_ID = "cli_feishu_app_id_1234567890"
|
||||
APP_SECRET = "feishu-app-secret-abcdefghij"
|
||||
TENANT_TOKEN = "fresh-tenant-token-value-do-not-leak"
|
||||
|
||||
# Captured before any test monkeypatches the class, so repeated injections in a
|
||||
# single test still wrap the real client instead of the previous factory.
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _make_channel(
|
||||
app_id: str = APP_ID,
|
||||
app_secret: str = APP_SECRET,
|
||||
domain: str = "feishu",
|
||||
) -> FeishuChannel:
|
||||
"""Build a Feishu channel that has NOT been started (no lark client)."""
|
||||
return FeishuChannel(
|
||||
{"app_id": app_id, "app_secret": app_secret, "domain": domain},
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
|
||||
def _inject_mock_transport(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
handler: "Any",
|
||||
) -> list[httpx.Request]:
|
||||
"""Route the probe's fresh ``httpx.AsyncClient`` through a MockTransport.
|
||||
|
||||
The probe owns its client, so the only seam is the client class itself. The
|
||||
real class is kept and a ``transport`` is supplied, so every other client
|
||||
behaviour (timeout argument, context-manager close) still runs for real.
|
||||
"""
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def recording_handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return handler(request)
|
||||
|
||||
transport = httpx.MockTransport(recording_handler)
|
||||
real_client_cls = _REAL_ASYNC_CLIENT
|
||||
|
||||
def factory(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
||||
kwargs["transport"] = transport
|
||||
return real_client_cls(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", factory)
|
||||
return requests
|
||||
|
||||
|
||||
def _run(coro: Any) -> Any:
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Feishu standalone probe — success
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_success_returns_ok_and_discards_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == FEISHU_TOKEN_URL
|
||||
assert request.method == "POST"
|
||||
assert json.loads(request.content.decode("utf-8")) == {
|
||||
"app_id": APP_ID,
|
||||
"app_secret": APP_SECRET,
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"code": 0, "tenant_access_token": TENANT_TOKEN, "expire": 7200},
|
||||
)
|
||||
|
||||
requests = _inject_mock_transport(monkeypatch, handler)
|
||||
channel = _make_channel()
|
||||
|
||||
result = _run(channel.test_connection())
|
||||
|
||||
assert len(requests) == 1
|
||||
assert result["ok"] is True
|
||||
assert result["code"] == "ok"
|
||||
assert result["sdk_available"] is FEISHU_AVAILABLE
|
||||
# The probe is standalone: it must not have created the lark SDK client.
|
||||
assert channel._client is None
|
||||
# The token and secret are discarded, never returned.
|
||||
serialized = json.dumps(result)
|
||||
assert TENANT_TOKEN not in serialized
|
||||
assert APP_SECRET not in serialized
|
||||
|
||||
|
||||
def test_success_uses_fresh_client_even_when_sdk_client_present(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A started channel's lark client must not be reused by the probe."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"tenant_access_token": TENANT_TOKEN})
|
||||
|
||||
requests = _inject_mock_transport(monkeypatch, handler)
|
||||
channel = _make_channel()
|
||||
sentinel = object()
|
||||
channel._client = sentinel # pretend start() ran
|
||||
|
||||
result = _run(channel.test_connection())
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(requests) == 1
|
||||
assert channel._client is sentinel
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Feishu standalone probe — domain routing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_lark_domain_hits_larksuite_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == LARK_TOKEN_URL
|
||||
return httpx.Response(200, json={"tenant_access_token": TENANT_TOKEN})
|
||||
|
||||
requests = _inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel(domain="lark").test_connection())
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
def test_default_domain_hits_feishu_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == FEISHU_TOKEN_URL
|
||||
return httpx.Response(200, json={"tenant_access_token": TENANT_TOKEN})
|
||||
|
||||
requests = _inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel().test_connection())
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Feishu standalone probe — invalid credentials
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [400, 401, 403])
|
||||
def test_http_client_error_reports_invalid_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status: int,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status, json={"msg": "invalid app_id/app_secret"})
|
||||
|
||||
_inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel().test_connection())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["code"] == "invalid_credentials"
|
||||
assert result["sdk_available"] is FEISHU_AVAILABLE
|
||||
assert "detail" in result
|
||||
assert APP_SECRET not in json.dumps(result)
|
||||
assert TENANT_TOKEN not in json.dumps(result)
|
||||
|
||||
|
||||
def test_http_200_error_body_reports_invalid_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Feishu answers bad credentials with HTTP 200 and an error body."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"code": 10014, "msg": "app secret invalid"})
|
||||
|
||||
_inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel().test_connection())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["code"] == "invalid_credentials"
|
||||
assert result["sdk_available"] is FEISHU_AVAILABLE
|
||||
assert APP_SECRET not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [b"null", b"[1, 2]", b'"oops"'])
|
||||
def test_http_200_non_object_json_body_reports_invalid_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
body: bytes,
|
||||
) -> None:
|
||||
"""A 200 with valid non-object JSON must classify, never raise."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=body)
|
||||
|
||||
_inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel().test_connection())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["code"] == "invalid_credentials"
|
||||
assert result["sdk_available"] is FEISHU_AVAILABLE
|
||||
assert APP_SECRET not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("app_id", "app_secret"),
|
||||
[("", ""), (APP_ID, ""), ("", APP_SECRET)],
|
||||
)
|
||||
def test_missing_credentials_short_circuit_without_network(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
app_id: str,
|
||||
app_secret: str,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover
|
||||
raise AssertionError("no network call may happen without credentials")
|
||||
|
||||
requests = _inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel(app_id, app_secret).test_connection())
|
||||
|
||||
assert requests == []
|
||||
assert result["ok"] is False
|
||||
assert result["code"] == "invalid_credentials"
|
||||
assert result["detail"] == "missing credentials"
|
||||
# The short-circuit envelope is self-contained like every other branch.
|
||||
assert result["sdk_available"] is FEISHU_AVAILABLE
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Feishu standalone probe — network
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_transport_error_reports_network(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
_inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel().test_connection())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["code"] == "network"
|
||||
assert result["sdk_available"] is FEISHU_AVAILABLE
|
||||
assert APP_SECRET not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503])
|
||||
def test_server_error_reports_network(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status: int,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status, text="upstream exploded")
|
||||
|
||||
_inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel().test_connection())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["code"] == "network"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Secret hygiene
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_probe_never_logs_secret_or_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
def ok_handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"tenant_access_token": TENANT_TOKEN})
|
||||
|
||||
_inject_mock_transport(monkeypatch, ok_handler)
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
success = _run(_make_channel().test_connection())
|
||||
|
||||
def reject_handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, text=f"bad secret {APP_SECRET}")
|
||||
|
||||
_inject_mock_transport(monkeypatch, reject_handler)
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
failure = _run(_make_channel().test_connection())
|
||||
|
||||
assert success["code"] == "ok"
|
||||
assert failure["code"] == "invalid_credentials"
|
||||
assert APP_SECRET not in caplog.text
|
||||
assert TENANT_TOKEN not in caplog.text
|
||||
# And the rejection body's echoed secret is scrubbed from the detail too.
|
||||
assert APP_SECRET not in json.dumps(failure)
|
||||
|
||||
|
||||
def test_rejection_detail_scrubs_echoed_secret_and_app_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
401, text=f"app_secret {APP_SECRET} is invalid for {APP_ID}"
|
||||
)
|
||||
|
||||
_inject_mock_transport(monkeypatch, handler)
|
||||
|
||||
result = _run(_make_channel().test_connection())
|
||||
|
||||
assert result["code"] == "invalid_credentials"
|
||||
assert APP_SECRET not in json.dumps(result)
|
||||
assert APP_ID not in json.dumps(result)
|
||||
assert "detail" in result
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Regression tests for ``FeishuChannel.stop()`` hot-swap safety.
|
||||
|
||||
Production defect (Feishu row of #1569, following the DingTalk zombie-stream
|
||||
fix in #1520): ``stop()`` only cleared ``_running``, but ``run_ws``'s loop
|
||||
blocks inside ``self._ws_client.start()`` → ``loop.run_until_complete(...)``
|
||||
which sleeps forever, and lark's receive loop runs with ``auto_reconnect=True``
|
||||
— so after a hot swap the old WebSocket stayed connected and BOTH the old and
|
||||
the replacement adapter answered messages. ``stop()`` must best-effort close
|
||||
the SDK connection and terminate the dedicated event loop so the thread exits.
|
||||
|
||||
The fake ws client mirrors the real lifecycle: ``start()`` blocks inside
|
||||
``run_until_complete`` on the thread's dedicated loop, exactly like lark's
|
||||
``Client.start()``, so only ``loop.stop()`` (from ``FeishuChannel.stop()``)
|
||||
can unwind it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.channels.bus.queue import MessageBus
|
||||
from src.channels.feishu import FeishuChannel
|
||||
|
||||
|
||||
class FakeConn:
|
||||
"""Stand-in for the ``websockets`` protocol object lark holds as ``_conn``."""
|
||||
|
||||
def __init__(self, *, hang: bool = False) -> None:
|
||||
self.close_calls = 0
|
||||
self.closed = threading.Event()
|
||||
self._hang = hang
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_calls += 1
|
||||
if self._hang:
|
||||
await asyncio.sleep(3600)
|
||||
self.closed.set()
|
||||
|
||||
|
||||
class FakeWsClient:
|
||||
"""Mimics ``lark.ws.Client``: ``start()`` blocks until the loop is stopped."""
|
||||
|
||||
def __init__(self, conn: FakeConn | None = None) -> None:
|
||||
self._conn = conn if conn is not None else FakeConn()
|
||||
self.start_calls = 0
|
||||
|
||||
def start(self) -> None:
|
||||
self.start_calls += 1
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(asyncio.sleep(3600))
|
||||
|
||||
|
||||
def _make_channel() -> FeishuChannel:
|
||||
return FeishuChannel({"app_id": "cli_a", "app_secret": "sec"}, MessageBus())
|
||||
|
||||
|
||||
def _start_ws_thread(
|
||||
channel: FeishuChannel, ws_client: FakeWsClient
|
||||
) -> tuple[threading.Thread, list[asyncio.AbstractEventLoop]]:
|
||||
"""Run the ``run_ws`` loop shape on a real thread with the fake client."""
|
||||
loops: list[asyncio.AbstractEventLoop] = []
|
||||
|
||||
def run_ws_like() -> None:
|
||||
ws_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(ws_loop)
|
||||
loops.append(ws_loop)
|
||||
channel._ws_loop = ws_loop
|
||||
try:
|
||||
while channel._running:
|
||||
try:
|
||||
ws_client.start()
|
||||
except Exception: # noqa: BLE001 - mirrors run_ws's warning branch
|
||||
pass
|
||||
if channel._running:
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
channel._ws_loop = None
|
||||
asyncio.set_event_loop(None)
|
||||
ws_loop.close()
|
||||
|
||||
channel._ws_client = ws_client
|
||||
channel._running = True
|
||||
thread = threading.Thread(target=run_ws_like, daemon=True)
|
||||
thread.start()
|
||||
return thread, loops
|
||||
|
||||
|
||||
async def _wait_until_ws_running(channel: FeishuChannel) -> None:
|
||||
"""Park until the dedicated loop is actually running inside ``start()``."""
|
||||
for _ in range(500):
|
||||
ws_loop = channel._ws_loop
|
||||
if ws_loop is not None and ws_loop.is_running():
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
pytest.fail("fake ws loop never started")
|
||||
|
||||
|
||||
def test_stop_terminates_ws_thread_and_closes_connection() -> None:
|
||||
"""Fails pre-fix: ``_running = False`` alone never unwound ``start()``."""
|
||||
|
||||
async def scenario() -> None:
|
||||
channel = _make_channel()
|
||||
ws_client = FakeWsClient()
|
||||
thread, loops = _start_ws_thread(channel, ws_client)
|
||||
await _wait_until_ws_running(channel)
|
||||
|
||||
await asyncio.wait_for(channel.stop(), timeout=5)
|
||||
await asyncio.to_thread(thread.join, 5.0)
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert ws_client._conn.close_calls == 1
|
||||
assert ws_client._conn.closed.is_set()
|
||||
# auto_reconnect must not resurrect the stream after the loop stop.
|
||||
assert ws_client.start_calls == 1
|
||||
assert channel._ws_loop is None
|
||||
assert loops[0].is_closed()
|
||||
|
||||
# A second stop() is a no-op.
|
||||
await asyncio.wait_for(channel.stop(), timeout=5)
|
||||
assert ws_client._conn.close_calls == 1
|
||||
assert ws_client.start_calls == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_stop_bounds_a_hanging_connection_close(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A wedged ``conn.close()`` must not wedge ``stop()``; the loop still dies."""
|
||||
|
||||
async def scenario() -> None:
|
||||
monkeypatch.setattr(FeishuChannel, "_WS_CLOSE_TIMEOUT_S", 0.05)
|
||||
channel = _make_channel()
|
||||
ws_client = FakeWsClient(FakeConn(hang=True))
|
||||
thread, _ = _start_ws_thread(channel, ws_client)
|
||||
await _wait_until_ws_running(channel)
|
||||
|
||||
await asyncio.wait_for(channel.stop(), timeout=5)
|
||||
await asyncio.to_thread(thread.join, 5.0)
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert ws_client._conn.close_calls == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_stop_without_start_is_safe() -> None:
|
||||
async def scenario() -> None:
|
||||
channel = _make_channel()
|
||||
|
||||
await channel.stop()
|
||||
|
||||
assert channel._ws_loop is None
|
||||
assert channel.is_running is False
|
||||
# Idempotent when never started.
|
||||
await channel.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -140,6 +140,21 @@ const GUIDE_DEFS: Record<
|
||||
docsLabel: t("settings.channels.guides.websocket.docsLabel"),
|
||||
}),
|
||||
},
|
||||
feishu: {
|
||||
docsUrl: "https://open.feishu.cn/",
|
||||
build: (t) => ({
|
||||
title: t("settings.channels.guides.feishu.title"),
|
||||
intro: t("settings.channels.guides.feishu.intro"),
|
||||
steps: [
|
||||
t("settings.channels.guides.feishu.step1"),
|
||||
t("settings.channels.guides.feishu.step2"),
|
||||
t("settings.channels.guides.feishu.step3"),
|
||||
t("settings.channels.guides.feishu.step4"),
|
||||
t("settings.channels.guides.feishu.step5"),
|
||||
],
|
||||
docsLabel: t("settings.channels.guides.feishu.docsLabel"),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export interface ChannelConfigPanelProps {
|
||||
@@ -262,6 +277,19 @@ export function ChannelConfigPanel({
|
||||
"settings.channels.fields.websocket.ping_timeout_s": { label: t("settings.channels.fields.websocket.ping_timeout_s.label"), help: t("settings.channels.fields.websocket.ping_timeout_s.help") },
|
||||
"settings.channels.fields.websocket.ssl_certfile": { label: t("settings.channels.fields.websocket.ssl_certfile.label"), help: t("settings.channels.fields.websocket.ssl_certfile.help") },
|
||||
"settings.channels.fields.websocket.ssl_keyfile": { label: t("settings.channels.fields.websocket.ssl_keyfile.label"), help: t("settings.channels.fields.websocket.ssl_keyfile.help") },
|
||||
"settings.channels.fields.feishu.app_id": { label: t("settings.channels.fields.feishu.app_id.label"), help: t("settings.channels.fields.feishu.app_id.help") },
|
||||
"settings.channels.fields.feishu.app_secret": { label: t("settings.channels.fields.feishu.app_secret.label"), help: t("settings.channels.fields.feishu.app_secret.help") },
|
||||
"settings.channels.fields.feishu.encrypt_key": { label: t("settings.channels.fields.feishu.encrypt_key.label"), help: t("settings.channels.fields.feishu.encrypt_key.help") },
|
||||
"settings.channels.fields.feishu.verification_token": { label: t("settings.channels.fields.feishu.verification_token.label"), help: t("settings.channels.fields.feishu.verification_token.help") },
|
||||
"settings.channels.fields.feishu.allow_from": { label: t("settings.channels.fields.feishu.allow_from.label"), help: t("settings.channels.fields.feishu.allow_from.help") },
|
||||
"settings.channels.fields.feishu.react_emoji": { label: t("settings.channels.fields.feishu.react_emoji.label"), help: t("settings.channels.fields.feishu.react_emoji.help") },
|
||||
"settings.channels.fields.feishu.done_emoji": { label: t("settings.channels.fields.feishu.done_emoji.label"), help: t("settings.channels.fields.feishu.done_emoji.help") },
|
||||
"settings.channels.fields.feishu.tool_hint_prefix": { label: t("settings.channels.fields.feishu.tool_hint_prefix.label"), help: t("settings.channels.fields.feishu.tool_hint_prefix.help") },
|
||||
"settings.channels.fields.feishu.group_policy": { label: t("settings.channels.fields.feishu.group_policy.label"), help: t("settings.channels.fields.feishu.group_policy.help") },
|
||||
"settings.channels.fields.feishu.reply_to_message": { label: t("settings.channels.fields.feishu.reply_to_message.label"), help: t("settings.channels.fields.feishu.reply_to_message.help") },
|
||||
"settings.channels.fields.feishu.streaming": { label: t("settings.channels.fields.feishu.streaming.label"), help: t("settings.channels.fields.feishu.streaming.help") },
|
||||
"settings.channels.fields.feishu.domain": { label: t("settings.channels.fields.feishu.domain.label"), help: t("settings.channels.fields.feishu.domain.help") },
|
||||
"settings.channels.fields.feishu.topic_isolation": { label: t("settings.channels.fields.feishu.topic_isolation.label"), help: t("settings.channels.fields.feishu.topic_isolation.help") },
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -244,6 +244,52 @@ function websocketEntry(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function feishuEntry(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
display_name: "Feishu",
|
||||
available: true,
|
||||
loaded: true,
|
||||
install_hint: "",
|
||||
error: "",
|
||||
supports_test: true,
|
||||
sdk_available: true,
|
||||
fields: [
|
||||
{ key: "app_id", type: "text", secret: false, required: true, help_key: "settings.channels.fields.feishu.app_id" },
|
||||
{ key: "app_secret", type: "password", secret: true, required: true, help_key: "settings.channels.fields.feishu.app_secret" },
|
||||
{ key: "encrypt_key", type: "password", secret: true, required: false, help_key: "settings.channels.fields.feishu.encrypt_key" },
|
||||
{ key: "verification_token", type: "password", secret: true, required: false, help_key: "settings.channels.fields.feishu.verification_token" },
|
||||
{ key: "allow_from", type: "list", secret: false, required: false, help_key: "settings.channels.fields.feishu.allow_from" },
|
||||
{ key: "react_emoji", type: "text", secret: false, required: false, help_key: "settings.channels.fields.feishu.react_emoji" },
|
||||
{ key: "done_emoji", type: "text", secret: false, required: false, help_key: "settings.channels.fields.feishu.done_emoji" },
|
||||
{ key: "tool_hint_prefix", type: "text", secret: false, required: false, help_key: "settings.channels.fields.feishu.tool_hint_prefix" },
|
||||
{ key: "group_policy", type: "text", secret: false, required: false, help_key: "settings.channels.fields.feishu.group_policy" },
|
||||
{ key: "reply_to_message", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.feishu.reply_to_message" },
|
||||
{ key: "streaming", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.feishu.streaming" },
|
||||
{ key: "domain", type: "text", secret: false, required: false, help_key: "settings.channels.fields.feishu.domain" },
|
||||
{ key: "topic_isolation", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.feishu.topic_isolation" },
|
||||
],
|
||||
values: {
|
||||
enabled: false,
|
||||
app_id: "cli_a9f8e7d6c5b4",
|
||||
allow_from: [],
|
||||
react_emoji: "THUMBSUP",
|
||||
done_emoji: "",
|
||||
tool_hint_prefix: "",
|
||||
group_policy: "mention",
|
||||
reply_to_message: true,
|
||||
streaming: true,
|
||||
domain: "feishu",
|
||||
topic_isolation: false,
|
||||
},
|
||||
secrets: {
|
||||
app_secret: { set: true, masked: "****abcd" },
|
||||
encrypt_key: { set: true, masked: "****ef01" },
|
||||
verification_token: { set: true, masked: "****2345" },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function channelsConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
config_path: "~/.vibe-trading/agent.json",
|
||||
@@ -300,6 +346,22 @@ async function renderWebsocketExpanded() {
|
||||
expect(await screen.findByDisplayValue("127.0.0.1")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
/** Render the card with the three guided IM channels available. */
|
||||
function guidedChannelsConfig() {
|
||||
return channelsConfig({
|
||||
channels: { dingtalk: dingtalkEntry(), qq: qqEntry(), feishu: feishuEntry() },
|
||||
});
|
||||
}
|
||||
|
||||
/** Render the card with the Feishu config panel expanded. */
|
||||
async function renderFeishuExpanded() {
|
||||
apiMock.getChannelsConfig.mockResolvedValue(guidedChannelsConfig());
|
||||
render(<ChannelSettings />);
|
||||
await screen.findByText("IM Channels");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Configure Feishu" }));
|
||||
expect(await screen.findByDisplayValue("cli_a9f8e7d6c5b4")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
describe("ChannelSettings config panel", () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
@@ -708,6 +770,85 @@ describe("ChannelSettings config panel", () => {
|
||||
expect(screen.queryByText(/keep 127\.0\.0\.1 for local-only use/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders every Feishu field from the backend help_keys with localized labels", async () => {
|
||||
await renderFeishuExpanded();
|
||||
|
||||
expect(screen.getByText("App ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("App Secret")).toBeInTheDocument();
|
||||
expect(screen.getByText("Encrypt Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Verification Token")).toBeInTheDocument();
|
||||
expect(screen.getByText("Allowed senders")).toBeInTheDocument();
|
||||
expect(screen.getByText("Working emoji")).toBeInTheDocument();
|
||||
expect(screen.getByText("Done emoji")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tool hint prefix")).toBeInTheDocument();
|
||||
expect(screen.getByText("Group policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Quote the original message")).toBeInTheDocument();
|
||||
expect(screen.getByText("Streaming replies")).toBeInTheDocument();
|
||||
expect(screen.getByText("Domain")).toBeInTheDocument();
|
||||
expect(screen.getByText("Per-topic sessions")).toBeInTheDocument();
|
||||
expect(screen.getByText(/starts with cli_/)).toBeInTheDocument();
|
||||
// Both Event Subscription secrets document the long-connection optionality.
|
||||
expect(screen.getAllByText(/Optional for long-connection mode/)).toHaveLength(2);
|
||||
expect(screen.getByText(/default THUMBSUP/)).toBeInTheDocument();
|
||||
// The three secret fields keep the generic masked placeholder and password type.
|
||||
for (const masked of ["****abcd", "****ef01", "****2345"]) {
|
||||
const secretInput = screen.getByPlaceholderText(`Keep current (${masked})`);
|
||||
expect(secretInput).toHaveAttribute("type", "password");
|
||||
expect(secretInput).toHaveValue("");
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles the Feishu setup guide with its steps and external link", async () => {
|
||||
await renderFeishuExpanded();
|
||||
|
||||
expect(screen.queryByText(/Create a Custom App on the Feishu Open Platform/)).not.toBeInTheDocument();
|
||||
|
||||
const guideToggle = screen.getByRole("button", { name: "Feishu / Lark bot setup" });
|
||||
expect(guideToggle).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(guideToggle);
|
||||
|
||||
expect(guideToggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText(/WebSocket long connection, so no public IP/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Create a Custom App on the Feishu Open Platform/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Add the Bot capability to the app/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/subscribe to receiving messages \(im\.message\.receive_v1\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Copy the App ID and App Secret from Credentials & Basic Info/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Create a version and publish the app/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Open the Feishu Open Platform" }))
|
||||
.toHaveAttribute("href", "https://open.feishu.cn/");
|
||||
|
||||
fireEvent.click(guideToggle);
|
||||
expect(guideToggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText(/Create a Custom App on the Feishu Open Platform/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never renders smuggled Feishu secret values, only the masked placeholders", async () => {
|
||||
// A contract-violating response that carries raw secrets in `values`
|
||||
// still must not reach the DOM: secret widgets bind to empty drafts.
|
||||
const feishu = feishuEntry({
|
||||
values: {
|
||||
...feishuEntry().values,
|
||||
app_secret: "leaked-app-secret",
|
||||
encrypt_key: "leaked-encrypt-key",
|
||||
verification_token: "leaked-verification-token",
|
||||
},
|
||||
});
|
||||
apiMock.getChannelsConfig.mockResolvedValue(channelsConfig({ channels: { feishu } }));
|
||||
render(<ChannelSettings />);
|
||||
await screen.findByText("IM Channels");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Configure Feishu" }));
|
||||
expect(await screen.findByDisplayValue("cli_a9f8e7d6c5b4")).toBeInTheDocument();
|
||||
|
||||
const secretInputs = Array.from(document.querySelectorAll("input[type=password]"));
|
||||
expect(secretInputs).toHaveLength(3);
|
||||
for (const secretInput of secretInputs) {
|
||||
expect(secretInput).toHaveValue("");
|
||||
}
|
||||
expect(document.body.textContent).not.toContain("leaked-app-secret");
|
||||
expect(document.body.textContent).not.toContain("leaked-encrypt-key");
|
||||
expect(document.body.textContent).not.toContain("leaked-verification-token");
|
||||
});
|
||||
|
||||
it("renders no setup guide for a channel without a guide definition", async () => {
|
||||
apiMock.getChannelsConfig.mockResolvedValue(channelsConfig({
|
||||
channels: {
|
||||
|
||||
@@ -712,6 +712,60 @@
|
||||
"label": "مسار مفتاح TLS",
|
||||
"help": "مسار المفتاح الخاص PEM على الخادم لـ WSS. اضبط الشهادة والمفتاح معًا أو اتركهما معًا؛ فهذه مسارات ملفات على الخادم وليست مواد مرفوعة."
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "معرّف التطبيق (App ID)",
|
||||
"help": "معرّف التطبيق من وحدة تحكم مطوّري Feishu / Lark (يبدأ بـ cli_)."
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "سر التطبيق (App Secret)",
|
||||
"help": "سر التطبيق. يُحفظ في الخادم ولا يُعاد إلى المتصفح أبدًا."
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "مفتاح التشفير (Encrypt Key)",
|
||||
"help": "مفتاح التشفير من إعدادات الاشتراك في الأحداث؛ اختياري في وضع الاتصال الدائم."
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "رمز التحقق (Verification Token)",
|
||||
"help": "رمز التحقق من إعدادات الاشتراك في الأحداث؛ اختياري في وضع الاتصال الدائم."
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "المُرسلون المسموح بهم",
|
||||
"help": "قائمة سماح اختيارية بمعرّفات مرسلين Feishu الذين يمكنهم محادثة الوكيل؛ اتركها فارغة للسماح لكل من يستطيع البوت رؤيته."
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "تفاعل حالة العمل",
|
||||
"help": "الرمز التعبيري الذي يُضاف إلى الرسالة الواردة أثناء عمل الوكيل (الافتراضي THUMBSUP)."
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "تفاعل الإنجاز",
|
||||
"help": "الرمز التعبيري الذي يُضاف عند اكتمال المهمة؛ اتركه فارغًا بلا إضافة."
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "بادئة تلميحات الأدوات",
|
||||
"help": "البادئة التي تُعرض قبل تلميحات الأدوات المضمّنة في الرد."
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "سياسة المجموعات",
|
||||
"help": "\"mention\" للرد فقط عند الإشارة إلى البوت في المجموعات، و\"open\" للرد على كل رسائل المجموعة."
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "اقتباس الرسالة الأصلية",
|
||||
"help": "الرد بالاقتباس على الرسالة الأصلية."
|
||||
},
|
||||
"streaming": {
|
||||
"label": "بث الرد تدريجيًا",
|
||||
"help": "بث الرد عبر التحرير الحي للبطاقات باستخدام CardKit."
|
||||
},
|
||||
"domain": {
|
||||
"label": "النطاق",
|
||||
"help": "استخدم \"feishu\" لنسخة Feishu في الصين (open.feishu.cn) و\"lark\" لنسخة Lark الدولية (open.larksuite.com)."
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "جلسات مستقلة لكل موضوع",
|
||||
"help": "تخصيص جلسة وكيل مستقلة لكل موضوع في المجموعة بدل جلسة واحدة مشتركة."
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -754,6 +808,16 @@
|
||||
"step4": "الحفظ أثناء تشغيل القناة يستبدل الخادم فوريًا: ينقطع العملاء المتصلون (بما في ذلك محادثة واجهة الويب هذه) لحظيًا ثم يعيدون الاتصال تلقائيًا.",
|
||||
"step5": "انقر «اختبار الاتصال»؛ فهو يتحقق محليًا من مادة شهادة/مفتاح SSL ومن توفر العنوان.",
|
||||
"docsLabel": "افتح وثائق Vibe-Trading"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "إعداد بوت Feishu / Lark",
|
||||
"intro": "يستخدم بوت Feishu الاتصال الدائم عبر WebSocket، لذا لا حاجة إلى عنوان IP عام أو رابط ويب هوك.",
|
||||
"step1": "أنشئ تطبيقًا مخصصًا في منصة Feishu للمطورين (open.feishu.cn)، أو أنشئه بالمسح الضوئي من الطرفية عبر الأمر vibe-trading channels login feishu حيث يُحفظ معرّف التطبيق والسر تلقائيًا.",
|
||||
"step2": "أضف ميزة البوت إلى التطبيق.",
|
||||
"step3": "في الاشتراك في الأحداث، اشترك في «استقبال الرسائل im.message.receive_v1» واختر وضع الاتصال الدائم.",
|
||||
"step4": "انسخ معرّف التطبيق (App ID) وسر التطبيق (App Secret) من صفحة «بيانات الاعتماد والمعلومات الأساسية» والصقهما في الحقلين أعلاه.",
|
||||
"step5": "أنشئ إصدارًا وانشر التطبيق (قد تتطلب مؤسستك موافقة المسؤول)، ثم فعّل القناة من هنا فتُطبَّق التغييرات فورًا دون إعادة تشغيل. في بيئات Lark الدولية اضبط النطاق على lark.",
|
||||
"docsLabel": "فتح منصة Feishu للمطورين"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +717,60 @@
|
||||
"label": "TLS-Schlüsselpfad",
|
||||
"help": "PEM-Schlüsselpfad auf dem Server für WSS. Zertifikat und Schlüssel gemeinsam setzen oder beide leer lassen; es sind Dateipfade auf dem Server, kein hochgeladenes Material."
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "Die App ID der Anwendung aus der Feishu-/Lark-Entwicklerkonsole (beginnt mit cli_)."
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "Das App Secret der Anwendung. Wird serverseitig gespeichert und nie an den Browser zurückgegeben."
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "Der Encrypt Key aus den Einstellungen zum Ereignisabo; im Long-Connection-Modus optional."
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "Der Verification Token aus den Einstellungen zum Ereignisabo; im Long-Connection-Modus optional."
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "Erlaubte Absender",
|
||||
"help": "Optionale Allowlist von Feishu-Absender-IDs, die mit dem Agenten sprechen dürfen; leer bedeutet alle, die der Bot sehen kann."
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "Arbeits-Emoji",
|
||||
"help": "Emoji-Reaktion auf eingehende Nachrichten, während der Agent arbeitet (Standard: THUMBSUP)."
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "Fertig-Emoji",
|
||||
"help": "Emoji-Reaktion nach Abschluss der Aufgabe; leer bedeutet keine."
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "Präfix für Tool-Hinweise",
|
||||
"help": "Präfix vor den inline angezeigten Tool-Hinweisen in der Antwort."
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "Gruppenrichtlinie",
|
||||
"help": "\"mention\" antwortet nur bei @-Erwähnung in Gruppen, \"open\" antwortet auf alle Gruppennachrichten."
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "Originalnachricht zitieren",
|
||||
"help": "Als Antwort die Originalnachricht zitieren."
|
||||
},
|
||||
"streaming": {
|
||||
"label": "Streaming-Antworten",
|
||||
"help": "Antwort per Live-Bearbeitung der CardKit-Karte streamen."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domain",
|
||||
"help": "\"feishu\" für Feishu China (open.feishu.cn), \"lark\" für das internationale Lark (open.larksuite.com)."
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "Sitzungen pro Thread",
|
||||
"help": "Jedem Gruppen-Thread eine eigene Agent-Sitzung geben, statt einer gemeinsamen pro Gruppe."
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -759,6 +813,16 @@
|
||||
"step4": "Speichern im laufenden Betrieb tauscht den Server live aus: Verbundene Clients (einschließlich dieses Web-UI-Chats) verlieren kurz die Verbindung und verbinden sich neu.",
|
||||
"step5": "Klicke auf „Verbindung testen“ – Zertifikat/Schlüssel für SSL und die Verfügbarkeit der Adresse werden lokal geprüft.",
|
||||
"docsLabel": "Vibe-Trading-Dokumentation öffnen"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "Feishu-/Lark-Bot einrichten",
|
||||
"intro": "Feishu-Bots nutzen die WebSocket-Long-Connection; eine öffentliche IP oder ein Webhook ist nicht nötig.",
|
||||
"step1": "Erstelle eine benutzerdefinierte App in der Feishu Open Platform (open.feishu.cn) — oder per QR-Scan im Terminal mit vibe-trading channels login feishu; App ID und App Secret werden dabei automatisch gespeichert.",
|
||||
"step2": "Füge der App die Bot-Funktion hinzu.",
|
||||
"step3": "Abonniere im Ereignisabo »Nachrichten empfangen im.message.receive_v1« und wähle den Long-Connection-Modus.",
|
||||
"step4": "Kopiere App ID und App Secret aus „Anmeldedaten & Basisinformationen“ und füge sie oben ein.",
|
||||
"step5": "Erstelle eine Version und veröffentliche die App (je nach Tenant mit Admin-Freigabe), dann aktiviere den Kanal hier — er wird ohne Neustart heiß übernommen. Für internationale Lark-Tenants die Domain auf lark setzen.",
|
||||
"docsLabel": "Feishu Open Platform öffnen"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +717,60 @@
|
||||
"label": "TLS key path",
|
||||
"help": "PEM private key path on the server for WSS. Set both certificate and key, or neither; these are file paths on the server, not uploaded material."
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "The app's App ID from the Feishu / Lark developer console (starts with cli_)."
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "The app's App Secret. Stored server-side and never sent back to the browser."
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "The Encrypt Key from the Event Subscription settings. Optional for long-connection mode."
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "The Verification Token from the Event Subscription settings. Optional for long-connection mode."
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "Allowed senders",
|
||||
"help": "Optional allowlist of Feishu sender IDs that may talk to the agent. Leave empty to allow everyone the bot can see."
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "Working emoji",
|
||||
"help": "Emoji reacted on an incoming message while the agent works (default THUMBSUP)."
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "Done emoji",
|
||||
"help": "Emoji reacted when the task completes. Leave empty for none."
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "Tool hint prefix",
|
||||
"help": "Prefix shown before inline tool hints in the reply."
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "Group policy",
|
||||
"help": "\"mention\" replies only when the bot is @mentioned in groups, \"open\" replies to all group messages."
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "Quote the original message",
|
||||
"help": "Quote-reply to the original message when answering."
|
||||
},
|
||||
"streaming": {
|
||||
"label": "Streaming replies",
|
||||
"help": "Stream the reply with CardKit live card edits."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domain",
|
||||
"help": "\"feishu\" for Feishu China (open.feishu.cn), \"lark\" for international Lark (open.larksuite.com)."
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "Per-topic sessions",
|
||||
"help": "Give each group topic its own agent session instead of sharing one session per group."
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -759,6 +813,16 @@
|
||||
"step4": "Saving while the channel is running hot-swaps the server: connected clients (including this Web UI chat) briefly disconnect and reconnect.",
|
||||
"step5": "Click Test connection — it validates the SSL certificate/key material and the address availability locally.",
|
||||
"docsLabel": "Open the Vibe-Trading docs"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "Feishu / Lark bot setup",
|
||||
"intro": "Feishu bots connect over a WebSocket long connection, so no public IP or webhook URL is needed.",
|
||||
"step1": "Create a Custom App on the Feishu Open Platform (open.feishu.cn), or scan-to-create it from the terminal with vibe-trading channels login feishu, which saves the App ID and App Secret automatically.",
|
||||
"step2": "Add the Bot capability to the app.",
|
||||
"step3": "Under Event Subscription, subscribe to receiving messages (im.message.receive_v1) and choose Long Connection mode.",
|
||||
"step4": "Copy the App ID and App Secret from Credentials & Basic Info and paste them into the fields above.",
|
||||
"step5": "Create a version and publish the app (admin approval if your tenant requires it), then enable the channel here — it hot-applies with no restart. For international Lark tenants, set domain to \"lark\".",
|
||||
"docsLabel": "Open the Feishu Open Platform"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +717,60 @@
|
||||
"label": "Ruta de la clave TLS",
|
||||
"help": "Ruta de la clave privada PEM en el servidor para WSS. Configura certificado y clave juntos o ninguno; son rutas de archivo del servidor, no material subido."
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "El App ID de la aplicación en la consola de desarrolladores de Feishu / Lark (empieza por cli_)."
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "El App Secret de la aplicación. Se guarda en el servidor y nunca se devuelve al navegador."
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "La Encrypt Key de la configuración de suscripción a eventos; opcional en el modo de conexión persistente."
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "El Verification Token de la configuración de suscripción a eventos; opcional en el modo de conexión persistente."
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "Remitentes permitidos",
|
||||
"help": "Lista blanca opcional de IDs de remitentes de Feishu autorizados a hablar con el agente; vacía significa todos los que el bot puede ver."
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "Emoji de procesamiento",
|
||||
"help": "Emoji con el que el bot reacciona al mensaje entrante mientras el agente trabaja (por defecto THUMBSUP)."
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "Emoji de finalización",
|
||||
"help": "Emoji con el que reacciona al completarse la tarea; vacío para no reaccionar."
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "Prefijo de pistas de herramientas",
|
||||
"help": "Prefijo que se muestra antes de las pistas de herramientas en línea de la respuesta."
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "Política de grupos",
|
||||
"help": "\"mention\": responde solo cuando se menciona al bot en grupos; \"open\": responde a todos los mensajes del grupo."
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "Citar el mensaje original",
|
||||
"help": "Responder citando el mensaje original."
|
||||
},
|
||||
"streaming": {
|
||||
"label": "Respuestas en streaming",
|
||||
"help": "Emite la respuesta con ediciones en vivo de tarjetas mediante CardKit."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Dominio",
|
||||
"help": "\"feishu\" para Feishu China (open.feishu.cn), \"lark\" para Lark internacional (open.larksuite.com)."
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "Sesiones por tema",
|
||||
"help": "Asigna a cada tema del grupo su propia sesión del agente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -759,6 +813,16 @@
|
||||
"step4": "Guardar con el canal en marcha cambia el servidor en caliente: los clientes conectados (incluido este chat de la interfaz web) se desconectan un momento y vuelven a conectarse.",
|
||||
"step5": "Haz clic en «Probar conexión»: valida localmente el certificado y la clave SSL y la disponibilidad de la dirección.",
|
||||
"docsLabel": "Abrir la documentación de Vibe-Trading"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "Configuración del bot de Feishu / Lark",
|
||||
"intro": "Los bots de Feishu usan una conexión persistente por WebSocket, así que no necesitas IP pública ni webhook.",
|
||||
"step1": "Crea una aplicación personalizada en la plataforma para desarrolladores de Feishu (open.feishu.cn), o créala por escaneo QR desde la terminal con vibe-trading channels login feishu, que guarda el App ID y el App Secret automáticamente.",
|
||||
"step2": "Añade la capacidad de bot a la aplicación.",
|
||||
"step3": "En Suscripción a eventos, suscríbete a «Recibir mensajes im.message.receive_v1» y elige el modo de conexión persistente.",
|
||||
"step4": "Copia el App ID y el App Secret desde Credenciales e información básica y pégalos en los campos de arriba.",
|
||||
"step5": "Crea una versión y publica la aplicación (con aprobación del administrador si tu tenant lo exige); después activa el canal: se aplica en caliente, sin reiniciar. En tenants internacionales de Lark, pon el dominio en lark.",
|
||||
"docsLabel": "Abrir la plataforma para desarrolladores de Feishu"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +717,60 @@
|
||||
"label": "Path kunci TLS",
|
||||
"help": "Path kunci privat PEM di server untuk WSS. Atur sertifikat dan kunci bersamaan, atau kosongkan keduanya; ini path berkas di server, bukan materi yang diunggah."
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "App ID aplikasi dari konsol pengembang Feishu / Lark (diawali cli_)."
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "App Secret aplikasi. Disimpan di sisi server dan tidak pernah dikirim kembali ke peramban."
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "Encrypt Key dari pengaturan langganan event; opsional untuk mode long connection."
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "Verification Token dari pengaturan langganan event; opsional untuk mode long connection."
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "Pengirim yang diizinkan",
|
||||
"help": "Daftar putih opsional ID pengirim Feishu yang boleh berbicara dengan agen; kosong berarti semua yang terlihat oleh bot."
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "Emoji saat bekerja",
|
||||
"help": "Emoji yang direaksikan ke pesan masuk sementara agen bekerja (bawaan THUMBSUP)."
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "Emoji saat selesai",
|
||||
"help": "Emoji yang direaksikan saat tugas selesai; kosongkan jika tidak ada."
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "Prefiks petunjuk tool",
|
||||
"help": "Prefiks yang tampil di depan petunjuk tool inline pada balasan."
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "Kebijakan grup",
|
||||
"help": "\"mention\" hanya membalas saat bot disebut (@mention) di grup, \"open\" membalas semua pesan grup."
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "Kutip pesan asli",
|
||||
"help": "Balas dengan mengutip pesan asli."
|
||||
},
|
||||
"streaming": {
|
||||
"label": "Balasan streaming",
|
||||
"help": "Mengalirkan balasan dengan penyuntingan kartu langsung melalui CardKit."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domain",
|
||||
"help": "\"feishu\" untuk Feishu China (open.feishu.cn) atau \"lark\" untuk Lark internasional (open.larksuite.com)."
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "Sesi per topik",
|
||||
"help": "Beri setiap topik grup sesi agennya sendiri, bukan satu sesi untuk seluruh grup."
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -759,6 +813,16 @@
|
||||
"step4": "Menyimpan saat kanal berjalan akan menukar server secara panas: klien yang terhubung (termasuk obrolan Web UI ini) sempat terputus lalu tersambung kembali.",
|
||||
"step5": "Klik «Uji koneksi»; pengujian memvalidasi materi sertifikat/kunci SSL dan ketersediaan alamat secara lokal.",
|
||||
"docsLabel": "Buka dokumentasi Vibe-Trading"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "Penyiapan bot Feishu / Lark",
|
||||
"intro": "Bot Feishu memakai long connection WebSocket, jadi tidak perlu IP publik maupun webhook.",
|
||||
"step1": "Buat Custom App di Feishu Open Platform (open.feishu.cn), atau buat lewat pindaian QR dari terminal dengan vibe-trading channels login feishu — App ID dan App Secret tersimpan otomatis.",
|
||||
"step2": "Tambahkan kemampuan Bot ke aplikasi.",
|
||||
"step3": "Di Langganan Event, berlangganan «Menerima pesan im.message.receive_v1» dan pilih mode Long Connection.",
|
||||
"step4": "Salin App ID dan App Secret dari halaman Kredensial & Info Dasar, lalu tempel di kolom di atas.",
|
||||
"step5": "Buat versi dan publikasikan aplikasi (perlu persetujuan admin jika tenant Anda mensyaratkannya), lalu aktifkan kanal di sini — berlaku seketika tanpa restart. Untuk tenant Lark internasional, set domain ke lark.",
|
||||
"docsLabel": "Buka Feishu Open Platform"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,6 +712,60 @@
|
||||
"label": "TLS 鍵パス",
|
||||
"help": "WSS 用のサーバー上の PEM 秘密鍵パス。証明書と鍵は両方を設定するか、両方空にしてください。アップロード素材ではなくサーバー上のファイルパスです。"
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "Feishu / Lark 開発者コンソールにあるアプリの App ID(cli_ で始まります)。"
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "アプリの App Secret。サーバー側に保存され、ブラウザに返されることはありません。"
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "イベント購読設定の Encrypt Key。ロングコネクションモードでは省略可能です。"
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "イベント購読設定の Verification Token。ロングコネクションモードでは省略可能です。"
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "許可する送信者",
|
||||
"help": "エージェントと会話できる Feishu の送信者 ID の許可リスト。空欄の場合はボットが見られる全員が対象になります。"
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "作業中リアクション",
|
||||
"help": "エージェントの作業中、受信したメッセージに付ける絵文字(デフォルトは THUMBSUP)。"
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "完了リアクション",
|
||||
"help": "タスク完了時に付ける絵文字。空欄の場合は付けません。"
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "ツールヒントの接頭辞",
|
||||
"help": "返信内のインラインツールヒントの前に表示される接頭辞。"
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "グループポリシー",
|
||||
"help": "「mention」はグループで @ メンションされたときのみ返信し、「open」はすべてのグループメッセージに返信します。"
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "元のメッセージを引用",
|
||||
"help": "返信ときに元のメッセージを引用して返します。"
|
||||
},
|
||||
"streaming": {
|
||||
"label": "ストリーミング返信",
|
||||
"help": "CardKit のライブ編集で返信をストリーミング出力します。"
|
||||
},
|
||||
"domain": {
|
||||
"label": "ドメイン",
|
||||
"help": "中国版 Feishu は「feishu」(open.feishu.cn)、国際版 Lark は「lark」(open.larksuite.com)。"
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "トピック別セッション",
|
||||
"help": "グループの各トピックに専用のエージェントセッションを割り当てます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -754,6 +808,16 @@
|
||||
"step4": "チャンネル実行中に保存するとサーバーがホットスワップされます。接続中のクライアント(この Web UI チャットを含む)は一時的に切断され、自動的に再接続します。",
|
||||
"step5": "「接続テスト」をクリックすると、SSL 証明書/鍵の素材とアドレスの可用性をローカルで検証します。",
|
||||
"docsLabel": "Vibe-Trading のドキュメントを開く"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "Feishu / Lark ボット設定ガイド",
|
||||
"intro": "Feishu ボットは WebSocket ロングコネクションで接続するため、公開 IP も Webhook も不要です。",
|
||||
"step1": "Feishu Open Platform(open.feishu.cn)でカスタムアプリを作成します。ターミナルで vibe-trading channels login feishu を実行すると、QR スキャンで作成でき、App ID と App Secret は自動的に保存されます。",
|
||||
"step2": "アプリにボット機能を追加します。",
|
||||
"step3": "イベント購読で「メッセージ受信 im.message.receive_v1」を購読し、ロングコネクションモードを選択します。",
|
||||
"step4": "「認証情報と基本情報」から App ID と App Secret をコピーして、上の欄に貼り付けます。",
|
||||
"step5": "バージョンを作成してアプリを公開し(テナントの仕様により管理者の承認が必要な場合があります)、ここでチャンネルを有効にすると再起動不要で即座に反映されます。国際版 Lark のテナントはドメインを lark に設定してください。",
|
||||
"docsLabel": "Feishu Open Platform を開く"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,6 +712,60 @@
|
||||
"label": "TLS 키 경로",
|
||||
"help": "WSS용 서버의 PEM 개인 키 경로입니다. 인증서와 키를 모두 설정하거나 모두 비워 두세요. 업로드 자료가 아니라 서버의 파일 경로입니다."
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "Feishu / Lark 개발자 콘솔의 앱 App ID(cli_ 로 시작)."
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "앱의 App Secret. 서버 측에만 저장되며 브라우저로는 절대 반환되지 않습니다."
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "이벤트 구독 설정의 Encrypt Key. 롱커넥션 모드에서는 선택 사항입니다."
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "이벤트 구독 설정의 Verification Token. 롱커넥션 모드에서는 선택 사항입니다."
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "허용된 발신자",
|
||||
"help": "에이전트와 대화할 수 있는 Feishu 발신자 ID 허용 목록. 비워 두면 봇이 볼 수 있는 모든 사용자에게 허용됩니다."
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "작업 중 이모지",
|
||||
"help": "에이전트가 작업하는 동안 들어온 메시지에 붙이는 이모지(기본값 THUMBSUP)."
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "완료 이모지",
|
||||
"help": "작업이 끝났을 때 붙이는 이모지. 비우면 붙이지 않습니다."
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "도구 힌트 접두사",
|
||||
"help": "답변의 인라인 도구 힌트 앞에 표시되는 접두사."
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "그룹 정책",
|
||||
"help": "\"mention\"은 그룹에서 @ 멘션될 때만 답변하고, \"open\"은 모든 그룹 메시지에 답변합니다."
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "원문 인용 답변",
|
||||
"help": "답변할 때 원문 메시지를 인용합니다."
|
||||
},
|
||||
"streaming": {
|
||||
"label": "스트리밍 답변",
|
||||
"help": "CardKit 라이브 편집으로 답변을 스트리밍합니다."
|
||||
},
|
||||
"domain": {
|
||||
"label": "도메인",
|
||||
"help": "중국용은 \"feishu\"(open.feishu.cn), 국제용 Lark는 \"lark\"(open.larksuite.com)."
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "토픽별 세션",
|
||||
"help": "그룹의 각 토픽에 별도 에이전트 세션을 할당합니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -754,6 +808,16 @@
|
||||
"step4": "채널 실행 중에 저장하면 서버가 핫 스왑됩니다. 연결된 클라이언트(이 웹 UI 채팅 포함)는 잠시 끊겼다가 자동으로 다시 연결됩니다.",
|
||||
"step5": "‘연결 테스트’를 클릭하면 SSL 인증서/키 자료와 주소 사용 가능 여부를 로컬에서 검증합니다.",
|
||||
"docsLabel": "Vibe-Trading 문서 열기"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "Feishu / Lark 봇 설정 가이드",
|
||||
"intro": "Feishu 봇은 WebSocket 롱커넥션으로 연결되므로 공개 IP나 웹훅이 필요 없습니다.",
|
||||
"step1": "Feishu 오픈 플랫폼(open.feishu.cn)에서 사용자 지정 앱을 만드세요. 터미널에서 vibe-trading channels login feishu 로 QR 스캔 생성을 사용하면 App ID와 App Secret이 자동 저장됩니다.",
|
||||
"step2": "앱에 봇 기능을 추가하세요.",
|
||||
"step3": "이벤트 구독에서 '메시지 받기 im.message.receive_v1'를 구독하고 롱커넥션 모드를 선택하세요.",
|
||||
"step4": "'자격 증명 및 기본 정보'에서 App ID와 App Secret을 복사해 위 입력칸에 붙여넣으세요.",
|
||||
"step5": "버전을 만들어 앱을 게시(테넌트에 따라 관리자 승인 필요)한 뒤 여기서 채널을 활성화하면 재시작 없이 즉시 적용됩니다. 국제 Lark 테넌트는 도메인을 lark로 설정하세요.",
|
||||
"docsLabel": "Feishu 오픈 플랫폼 열기"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +717,60 @@
|
||||
"label": "Caminho da chave TLS",
|
||||
"help": "Caminho da chave privada PEM no servidor para WSS. Defina certificado e chave juntos ou nenhum dos dois; são caminhos de arquivo no servidor, não material enviado por upload."
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "O App ID do aplicativo no console de desenvolvedores da Feishu / Lark (começa com cli_)."
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "O App Secret do aplicativo. Armazenado no servidor e nunca devolvido ao navegador."
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "O Encrypt Key das configurações de assinatura de eventos; opcional no modo de conexão contínua."
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "O Verification Token das configurações de assinatura de eventos; opcional no modo de conexão contínua."
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "Remetentes permitidos",
|
||||
"help": "Lista de permissão opcional com IDs de remetentes do Feishu autorizados a falar com o agente; vazia permite todos que o bot consegue ver."
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "Emoji de processamento",
|
||||
"help": "Emoji reagido à mensagem recebida enquanto o agente trabalha (padrão THUMBSUP)."
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "Emoji de conclusão",
|
||||
"help": "Emoji reagido quando a tarefa é concluída; vazio para nenhum."
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "Prefixo das dicas de ferramentas",
|
||||
"help": "Prefixo exibido antes das dicas de ferramentas em linha na resposta."
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "Política de grupos",
|
||||
"help": "\"mention\" responde apenas quando o bot é mencionado em grupos; \"open\" responde a todas as mensagens do grupo."
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "Citar a mensagem original",
|
||||
"help": "Responder citando a mensagem original."
|
||||
},
|
||||
"streaming": {
|
||||
"label": "Respostas em streaming",
|
||||
"help": "Transmitir a resposta com edições ao vivo do cartão via CardKit."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domínio",
|
||||
"help": "\"feishu\" para Feishu China (open.feishu.cn), \"lark\" para o Lark internacional (open.larksuite.com)."
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "Sessões por tópico",
|
||||
"help": "Dar a cada tópico do grupo sua própria sessão do agente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -759,6 +813,16 @@
|
||||
"step4": "Salvar com o canal em execução troca o servidor a quente: os clientes conectados (incluindo este chat da interface web) caem por um instante e se reconectam.",
|
||||
"step5": "Clique em «Testar conexão»: ele valida localmente o certificado e a chave SSL e a disponibilidade do endereço.",
|
||||
"docsLabel": "Abrir a documentação do Vibe-Trading"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "Configuração do bot Feishu / Lark",
|
||||
"intro": "Bots do Feishu usam a conexão contínua por WebSocket, então não é preciso IP público nem webhook.",
|
||||
"step1": "Crie um aplicativo personalizado na plataforma de desenvolvedores do Feishu (open.feishu.cn) — ou crie por leitura de QR no terminal com vibe-trading channels login feishu, que salva o App ID e o App Secret automaticamente.",
|
||||
"step2": "Adicione a capacidade de bot ao aplicativo.",
|
||||
"step3": "Em Assinatura de eventos, assine «Receber mensagens im.message.receive_v1» e escolha o modo de conexão contínua.",
|
||||
"step4": "Copie o App ID e o App Secret em Credenciais e informações básicas e cole-os nos campos acima.",
|
||||
"step5": "Crie uma versão e publique o aplicativo (com aprovação do administrador se o seu tenant exigir); em seguida, ative o canal aqui — a aplicação é imediata, sem reiniciar. Em tenants internacionais do Lark, defina o domínio como lark.",
|
||||
"docsLabel": "Abrir a plataforma de desenvolvedores do Feishu"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,6 +712,60 @@
|
||||
"label": "TLS 私钥路径",
|
||||
"help": "WSS 使用的服务端 PEM 私钥路径。证书与私钥需成对设置或同时留空;这里是服务器上的文件路径,不是上传的材料。"
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"app_id": {
|
||||
"label": "App ID",
|
||||
"help": "飞书 / Lark 开放平台开发者后台中应用的 App ID(以 cli_ 开头)。"
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret",
|
||||
"help": "应用的 App Secret,保存在服务端,绝不会回传到浏览器。"
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key(加密密钥)",
|
||||
"help": "事件订阅设置中的 Encrypt Key;使用长连接模式时可不填。"
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token(验证令牌)",
|
||||
"help": "事件订阅设置中的 Verification Token;使用长连接模式时可不填。"
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "允许的发送者",
|
||||
"help": "允许与智能体对话的飞书发送者 ID 白名单;留空表示机器人可见范围内的所有人都可对话。"
|
||||
},
|
||||
"react_emoji": {
|
||||
"label": "处理中表情",
|
||||
"help": "智能体处理期间对收到的消息附加的表情(默认 THUMBSUP)。"
|
||||
},
|
||||
"done_emoji": {
|
||||
"label": "完成表情",
|
||||
"help": "任务完成时附加的表情;留空表示不附加。"
|
||||
},
|
||||
"tool_hint_prefix": {
|
||||
"label": "工具提示前缀",
|
||||
"help": "回复中内联工具提示前显示的前缀。"
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "群聊策略",
|
||||
"help": "“mention”表示群聊中仅在被 @ 提及时回复;“open”表示回复所有群消息。"
|
||||
},
|
||||
"reply_to_message": {
|
||||
"label": "引用原消息",
|
||||
"help": "回复时引用(quote-reply)原消息。"
|
||||
},
|
||||
"streaming": {
|
||||
"label": "流式回复",
|
||||
"help": "通过 CardKit 实时编辑卡片,将回复以流式方式输出。"
|
||||
},
|
||||
"domain": {
|
||||
"label": "域名",
|
||||
"help": "中国版飞书填“feishu”(open.feishu.cn),国际版 Lark 填“lark”(open.larksuite.com)。"
|
||||
},
|
||||
"topic_isolation": {
|
||||
"label": "按话题隔离会话",
|
||||
"help": "群聊中每个话题使用独立的智能体会话,而不是整群共享一个会话。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
@@ -754,6 +808,16 @@
|
||||
"step4": "渠道运行中保存会热切换服务:已连接的客户端(包括本 Web UI 聊天)会短暂断开并自动重连。",
|
||||
"step5": "点击“测试连接”,它会在本地校验 SSL 证书/私钥与地址可用性。",
|
||||
"docsLabel": "打开 Vibe-Trading 文档"
|
||||
},
|
||||
"feishu": {
|
||||
"title": "飞书 / Lark 机器人配置指南",
|
||||
"intro": "飞书机器人通过 WebSocket 长连接接入,无需公网 IP 或回调地址。",
|
||||
"step1": "在飞书开放平台(open.feishu.cn)创建企业自建应用;也可在终端运行 vibe-trading channels login feishu 扫码创建,App ID 与 App Secret 会自动保存。",
|
||||
"step2": "为应用添加机器人能力。",
|
||||
"step3": "在事件订阅中订阅“接收消息 im.message.receive_v1”,并选择长连接模式。",
|
||||
"step4": "在“凭证与基础信息”页面复制 App ID 与 App Secret,填入上方字段。",
|
||||
"step5": "创建版本并发布应用(若租户要求,需管理员审批),然后回到这里启用渠道——即时生效、无需重启;国际版 Lark 租户请将域名设为“lark”。",
|
||||
"docsLabel": "打开飞书开放平台"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2022,3 +2086,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user