mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 21:36:06 +00:00
feat(platform): show deployment outbound IPs on adapter config forms
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 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,18 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
@@ -49,6 +61,7 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
'disable_models_service', False
|
'disable_models_service', False
|
||||||
),
|
),
|
||||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||||
|
'outbound_ips': outbound_ips,
|
||||||
'wizard_status': wizard_status,
|
'wizard_status': wizard_status,
|
||||||
'wizard_progress': wizard_progress,
|
'wizard_progress': wizard_progress,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ spec:
|
|||||||
type: webhook-url
|
type: webhook-url
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
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
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://link.langbot.app/en/platforms/qqofficial
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||||
config:
|
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
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ spec:
|
|||||||
type: webhook-url
|
type: webhook-url
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
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
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ spec:
|
|||||||
field: enable-webhook
|
field: enable-webhook
|
||||||
operator: eq
|
operator: eq
|
||||||
value: true
|
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
|
- name: Secret
|
||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ spec:
|
|||||||
type: webhook-url
|
type: webhook-url
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
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
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ system:
|
|||||||
recovery_key: ''
|
recovery_key: ''
|
||||||
allow_modify_login_info: true
|
allow_modify_login_info: true
|
||||||
disabled_adapters: []
|
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:
|
limitation:
|
||||||
max_bots: -1
|
max_bots: -1
|
||||||
max_pipelines: -1
|
max_pipelines: -1
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
|||||||
import { UUID } from 'uuidjs';
|
import { UUID } from 'uuidjs';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { systemInfo } from '@/app/infra/http';
|
||||||
import { Bot } from '@/app/infra/entities/api';
|
import { Bot } from '@/app/infra/entities/api';
|
||||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
@@ -621,6 +622,7 @@ export default function BotForm({
|
|||||||
extra_webhook_url: extraWebhookUrl,
|
extra_webhook_url: extraWebhookUrl,
|
||||||
bot_uuid: initBotId || '',
|
bot_uuid: initBotId || '',
|
||||||
adapter_config: form.getValues('adapter_config') || {},
|
adapter_config: form.getValues('adapter_config') || {},
|
||||||
|
outbound_ips: systemInfo.outbound_ips,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -44,8 +47,8 @@ function resolveShowIfValue(
|
|||||||
externalDependentValues?: Record<string, unknown>,
|
externalDependentValues?: Record<string, unknown>,
|
||||||
systemContext?: Record<string, unknown>,
|
systemContext?: Record<string, unknown>,
|
||||||
): unknown {
|
): unknown {
|
||||||
if (field.startsWith('__system.')) {
|
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||||
const key = field.slice('__system.'.length);
|
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
|
||||||
return systemContext?.[key];
|
return systemContext?.[key];
|
||||||
}
|
}
|
||||||
if (watchedValues[field] !== undefined) {
|
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<number | null>(null);
|
||||||
|
|
||||||
|
const handleCopy = (text: string, index: number) => {
|
||||||
|
copyToClipboard(text).catch(() => {});
|
||||||
|
setCopiedIndex(index);
|
||||||
|
setTimeout(() => setCopiedIndex(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="min-w-0">
|
||||||
|
<FormLabel className="break-words">{label}</FormLabel>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{values.map((value, index) => (
|
||||||
|
<div key={index} className="flex min-w-0 items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
readOnly
|
||||||
|
className="min-w-0 flex-1 bg-muted"
|
||||||
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopy(value, index)}
|
||||||
|
>
|
||||||
|
{copiedIndex === index ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm break-words text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Hover-only Radix tooltips never open on touch devices (no pointer hover),
|
// 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
|
// 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
|
// mobile. This wrapper makes the info icon also toggle the tooltip on tap while
|
||||||
@@ -290,15 +353,17 @@ export default function DynamicFormComponent({
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not
|
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
||||||
// participate in form state, validation, or value emission.
|
// and `__system.*`-named fields) that should not participate in form state,
|
||||||
|
// validation, or value emission.
|
||||||
const editableItems = useMemo(
|
const editableItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
itemConfigList.filter(
|
itemConfigList.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.type !== 'webhook-url' &&
|
item.type !== 'webhook-url' &&
|
||||||
item.type !== 'embed-code' &&
|
item.type !== 'embed-code' &&
|
||||||
item.type !== 'qr-code-login',
|
item.type !== 'qr-code-login' &&
|
||||||
|
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
|
||||||
),
|
),
|
||||||
[itemConfigList],
|
[itemConfigList],
|
||||||
);
|
);
|
||||||
@@ -583,6 +648,31 @@ export default function DynamicFormComponent({
|
|||||||
<DisabledTooltipIcon text={disabledTooltip} />
|
<DisabledTooltipIcon text={disabledTooltip} />
|
||||||
) : null;
|
) : 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 (
|
||||||
|
<SystemInfoField
|
||||||
|
key={config.id}
|
||||||
|
label={extractI18nObject(config.label)}
|
||||||
|
description={
|
||||||
|
config.description
|
||||||
|
? extractI18nObject(config.description)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Webhook URL fields are display-only; render outside of form binding
|
// Webhook URL fields are display-only; render outside of form binding
|
||||||
if (config.type === 'webhook-url') {
|
if (config.type === 'webhook-url') {
|
||||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DynamicFormItemType,
|
DynamicFormItemType,
|
||||||
IDynamicFormItemOption,
|
IDynamicFormItemOption,
|
||||||
IShowIfCondition,
|
IShowIfCondition,
|
||||||
|
SYSTEM_FIELD_PREFIX,
|
||||||
} from '@/app/infra/entities/form/dynamic';
|
} from '@/app/infra/entities/form/dynamic';
|
||||||
import { I18nObject } from '@/app/infra/entities/common';
|
import { I18nObject } from '@/app/infra/entities/common';
|
||||||
|
|
||||||
@@ -50,6 +51,11 @@ export function getDefaultValues(
|
|||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
return itemConfigList.reduce(
|
return itemConfigList.reduce(
|
||||||
(acc, item) => {
|
(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;
|
acc[item.name] = item.default;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -348,6 +348,10 @@ export interface ApiRespSystemInfo {
|
|||||||
allow_modify_login_info: boolean;
|
allow_modify_login_info: boolean;
|
||||||
disable_models_service: boolean;
|
disable_models_service: boolean;
|
||||||
limitation: SystemLimitation;
|
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_status: string; // 'none' | 'skipped' | 'completed'
|
||||||
wizard_progress: WizardProgress | null;
|
wizard_progress: WizardProgress | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { I18nObject } from '@/app/infra/entities/common';
|
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 {
|
export interface IShowIfCondition {
|
||||||
field: string;
|
field: string;
|
||||||
operator: 'eq' | 'neq' | 'in';
|
operator: 'eq' | 'neq' | 'in';
|
||||||
@@ -11,6 +16,12 @@ export interface IDynamicFormItemSchema {
|
|||||||
id: string;
|
id: string;
|
||||||
default: string | number | boolean | Array<unknown>;
|
default: string | number | boolean | Array<unknown>;
|
||||||
label: I18nObject;
|
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;
|
name: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
type: DynamicFormItemType;
|
type: DynamicFormItemType;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const systemInfo: ApiRespSystemInfo = {
|
|||||||
max_pipelines: -1,
|
max_pipelines: -1,
|
||||||
max_extensions: -1,
|
max_extensions: -1,
|
||||||
},
|
},
|
||||||
|
outbound_ips: [],
|
||||||
wizard_status: 'none',
|
wizard_status: 'none',
|
||||||
wizard_progress: null,
|
wizard_progress: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -939,6 +939,7 @@ function StepBotConfig({
|
|||||||
is_wizard: true,
|
is_wizard: true,
|
||||||
webhook_url: webhookUrl,
|
webhook_url: webhookUrl,
|
||||||
extra_webhook_url: extraWebhookUrl,
|
extra_webhook_url: extraWebhookUrl,
|
||||||
|
outbound_ips: systemInfo.outbound_ips,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user