From bca710dbd471710226bba0eaf0bcd60add2698b7 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 10 Jun 2026 19:41:14 +0800 Subject: [PATCH] feat(platform): show deployment outbound IPs on adapter config forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud/NAT deployments couldn't complete WeCom-family / Official Account / QQ Official setup because the trusted-IP (IP whitelist) value — the server's egress IPs — was nowhere visible in LangBot. - config.yaml: new system.outbound_ips list (env: SYSTEM__OUTBOUND_IPS, comma-separated), exposed via GET /api/v1/system/info - dynamic form: generic __system.*-named display-only fields resolved from systemContext (same namespace as show_if), one read-only row per value with a copy button, excluded from form state and emitted values; hidden entirely when the deployment provides no IPs - manifests: trusted-IP display field for wecom, wecomcs, wecombot, officialaccount, qqofficial Co-Authored-By: Claude Fable 5 --- .../pkg/api/http/controller/groups/system.py | 13 +++ .../pkg/platform/sources/officialaccount.yaml | 12 +++ .../pkg/platform/sources/qqofficial.yaml | 12 +++ src/langbot/pkg/platform/sources/wecom.yaml | 12 +++ .../pkg/platform/sources/wecombot.yaml | 12 +++ src/langbot/pkg/platform/sources/wecomcs.yaml | 12 +++ src/langbot/templates/config.yaml | 7 ++ .../home/bots/components/bot-form/BotForm.tsx | 2 + .../dynamic-form/DynamicFormComponent.tsx | 102 ++++++++++++++++-- .../dynamic-form/DynamicFormItemConfig.ts | 6 ++ web/src/app/infra/entities/api/index.ts | 4 + web/src/app/infra/entities/form/dynamic.ts | 11 ++ web/src/app/infra/http/index.ts | 1 + web/src/app/wizard/page.tsx | 1 + 14 files changed, 201 insertions(+), 6 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index e6c10fc0..236a2358 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -31,6 +31,18 @@ class SystemRouterGroup(group.RouterGroup): except Exception: pass + # ``system.outbound_ips`` may be a comma-separated string instead of + # a list when injected via the SYSTEM__OUTBOUND_IPS env var into a + # pre-existing data/config.yaml that lacks the key (env overrides + # only coerce to list when the key already holds one). + outbound_ips = self.ap.instance_config.data.get('system', {}).get('outbound_ips', []) + if isinstance(outbound_ips, str): + outbound_ips = [ip.strip() for ip in outbound_ips.split(',') if ip.strip()] + elif isinstance(outbound_ips, list): + outbound_ips = [str(ip).strip() for ip in outbound_ips if str(ip).strip()] + else: + outbound_ips = [] + return self.success( data={ 'version': constants.semantic_version, @@ -49,6 +61,7 @@ class SystemRouterGroup(group.RouterGroup): 'disable_models_service', False ), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), + 'outbound_ips': outbound_ips, 'wizard_status': wizard_status, 'wizard_progress': wizard_progress, } diff --git a/src/langbot/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml index 9376c3fd..d0953802 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.yaml +++ b/src/langbot/pkg/platform/sources/officialaccount.yaml @@ -31,6 +31,18 @@ spec: type: webhook-url required: false default: "" + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the "Basic Configuration" of the WeChat Official Account platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到微信公众平台「基本配置」中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入微信公眾平台「基本配置」中的 IP 白名單 + type: array[string] + required: false + default: [] - name: token label: en_US: Token diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index f6afdabc..d66a770b 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -19,6 +19,18 @@ spec: en: https://link.langbot.app/en/platforms/qqofficial ja: https://link.langbot.app/ja/platforms/qqofficial config: + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the development settings of the QQ Open Platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到 QQ 开放平台开发设置中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入 QQ 開放平台開發設定中的 IP 白名單 + type: array[string] + required: false + default: [] - name: appid label: en_US: App ID diff --git a/src/langbot/pkg/platform/sources/wecom.yaml b/src/langbot/pkg/platform/sources/wecom.yaml index d232414b..509fa4e0 100644 --- a/src/langbot/pkg/platform/sources/wecom.yaml +++ b/src/langbot/pkg/platform/sources/wecom.yaml @@ -32,6 +32,18 @@ spec: type: webhook-url required: false default: "" + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of your app in the WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台应用详情页的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台應用詳情頁的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: corpid label: en_US: Corpid diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 5f65dea6..7e95402e 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -75,6 +75,18 @@ spec: field: enable-webhook operator: eq value: true + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of the bot configuration in the WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台智能机器人配置的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台智慧機器人設定的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: Secret label: en_US: Secret diff --git a/src/langbot/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml index 27abc010..521e7bb0 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.yaml +++ b/src/langbot/pkg/platform/sources/wecomcs.yaml @@ -31,6 +31,18 @@ spec: type: webhook-url required: false default: "" + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of WeChat Customer Service in the WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台微信客服的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台微信客服的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: corpid label: en_US: Corpid diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index fc24f921..c0002e8f 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -21,6 +21,13 @@ system: recovery_key: '' allow_modify_login_info: true disabled_adapters: [] + # Public outbound IP addresses of this LangBot deployment. Some platforms + # (e.g. WeCom, WeChat Official Account, QQ Official API) require the + # caller's IPs to be added to their trusted-IP / IP-whitelist settings. + # When set, the web UI shows these IPs on the bot config form of such + # adapters. Also settable via the SYSTEM__OUTBOUND_IPS env var + # (comma-separated). Empty list = hidden in the web UI. + outbound_ips: [] limitation: max_bots: -1 max_pipelines: -1 diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index c81225de..3b3aafb6 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -13,6 +13,7 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { UUID } from 'uuidjs'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import { httpClient } from '@/app/infra/http/HttpClient'; +import { systemInfo } from '@/app/infra/http'; import { Bot } from '@/app/infra/entities/api'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { ExternalLink } from 'lucide-react'; @@ -621,6 +622,7 @@ export default function BotForm({ extra_webhook_url: extraWebhookUrl, bot_uuid: initBotId || '', adapter_config: form.getValues('adapter_config') || {}, + outbound_ips: systemInfo.outbound_ips, }} /> )} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 551aa1dd..a9c4810b 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -1,4 +1,7 @@ -import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; +import { + IDynamicFormItemSchema, + SYSTEM_FIELD_PREFIX, +} from '@/app/infra/entities/form/dynamic'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -44,8 +47,8 @@ function resolveShowIfValue( externalDependentValues?: Record, systemContext?: Record, ): unknown { - if (field.startsWith('__system.')) { - const key = field.slice('__system.'.length); + if (field.startsWith(SYSTEM_FIELD_PREFIX)) { + const key = field.slice(SYSTEM_FIELD_PREFIX.length); return systemContext?.[key]; } if (watchedValues[field] !== undefined) { @@ -198,6 +201,66 @@ function WebhookUrlField({ ); } +/** + * Display-only component for `__system.*` fields (e.g. the deployment's + * outbound IPs that the operator must add to a platform's trusted-IP list). + * Renders one read-only row per value, each with a copy button. Rendered + * outside of react-hook-form binding since the values come from + * systemContext, not user input. + */ +function SystemInfoField({ + label, + description, + values, +}: { + label: string; + description?: string; + values: string[]; +}) { + const [copiedIndex, setCopiedIndex] = useState(null); + + const handleCopy = (text: string, index: number) => { + copyToClipboard(text).catch(() => {}); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + }; + + return ( + + {label} +
+ {values.map((value, index) => ( +
+ (e.target as HTMLInputElement).select()} + /> + +
+ ))} +
+ {description && ( +

+ {description} +

+ )} +
+ ); +} + // Hover-only Radix tooltips never open on touch devices (no pointer hover), // so the ``disabled_tooltip`` explaining why a field is locked was invisible on // mobile. This wrapper makes the info icon also toggle the tooltip on tap while @@ -290,15 +353,17 @@ export default function DynamicFormComponent({ return value; }; - // Filter out display-only field types (e.g. webhook-url, embed-code) that should not - // participate in form state, validation, or value emission. + // Filter out display-only fields (webhook-url/embed-code/qr-code-login types + // and `__system.*`-named fields) that should not participate in form state, + // validation, or value emission. const editableItems = useMemo( () => itemConfigList.filter( (item) => item.type !== 'webhook-url' && item.type !== 'embed-code' && - item.type !== 'qr-code-login', + item.type !== 'qr-code-login' && + !item.name.startsWith(SYSTEM_FIELD_PREFIX), ), [itemConfigList], ); @@ -583,6 +648,31 @@ export default function DynamicFormComponent({ ) : null; + // `__system.*` fields are display-only; their value is resolved + // from systemContext (same namespace as show_if), not user input. + // Hidden entirely when the deployment doesn't provide the value. + if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) { + const rawValue = + systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)]; + const values = (Array.isArray(rawValue) ? rawValue : [rawValue]) + .filter((v) => v !== undefined && v !== null && v !== '') + .map(String); + if (values.length === 0) return null; + + return ( + + ); + } + // Webhook URL fields are display-only; render outside of form binding if (config.type === 'webhook-url') { const webhookUrl = (systemContext?.webhook_url as string) || ''; diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts index 50ac578a..b11e09d2 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts @@ -3,6 +3,7 @@ import { DynamicFormItemType, IDynamicFormItemOption, IShowIfCondition, + SYSTEM_FIELD_PREFIX, } from '@/app/infra/entities/form/dynamic'; import { I18nObject } from '@/app/infra/entities/common'; @@ -50,6 +51,11 @@ export function getDefaultValues( ): Record { return itemConfigList.reduce( (acc, item) => { + // `__system.*` fields are display-only (resolved from systemContext); + // their placeholder defaults must not leak into the config values. + if (item.name.startsWith(SYSTEM_FIELD_PREFIX)) { + return acc; + } acc[item.name] = item.default; return acc; }, diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index aa1e2c8b..b9c3a90f 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -348,6 +348,10 @@ export interface ApiRespSystemInfo { allow_modify_login_info: boolean; disable_models_service: boolean; limitation: SystemLimitation; + /** Public outbound IPs of the deployment (``system.outbound_ips`` in + * config.yaml). Shown on adapter config forms whose platform requires + * trusted-IP / IP-whitelist settings. Empty = not configured. */ + outbound_ips: string[]; wizard_status: string; // 'none' | 'skipped' | 'completed' wizard_progress: WizardProgress | null; } diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 01411397..e2dca5c3 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -1,5 +1,10 @@ import { I18nObject } from '@/app/infra/entities/common'; +/** Namespace prefix shared by ``show_if.field`` references and display-only + * form item names whose value is resolved from the caller-supplied + * ``DynamicFormComponent.systemContext``. */ +export const SYSTEM_FIELD_PREFIX = '__system.'; + export interface IShowIfCondition { field: string; operator: 'eq' | 'neq' | 'in'; @@ -11,6 +16,12 @@ export interface IDynamicFormItemSchema { id: string; default: string | number | boolean | Array; label: I18nObject; + /** Form value key. Names prefixed with ``__system.`` denote display-only + * fields whose value is resolved from + * ``DynamicFormComponent.systemContext`` (e.g. ``__system.outbound_ips`` + * → ``systemContext.outbound_ips``) — same namespace as ``show_if``. + * Such fields are rendered read-only with copy buttons, excluded from + * form state/validation/emission, and hidden when the value is empty. */ name: string; required: boolean; type: DynamicFormItemType; diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index 98b660c6..55fb6350 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -16,6 +16,7 @@ export const systemInfo: ApiRespSystemInfo = { max_pipelines: -1, max_extensions: -1, }, + outbound_ips: [], wizard_status: 'none', wizard_progress: null, }; diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index e823fd29..1fd39375 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -939,6 +939,7 @@ function StepBotConfig({ is_wizard: true, webhook_url: webhookUrl, extra_webhook_url: extraWebhookUrl, + outbound_ips: systemInfo.outbound_ips, }} />