feat(im): describe IM platforms and credentials from the backend

Each IM platform now declares its connection modes, thread support,
console links and a credential config schema in internal/im/platforms.go.
Fields used by one mode carry x-visible-if on "$mode", which config
schemas read from a context (configschema.ValidateInContext, the
SchemaForm context prop) instead of from sibling fields.

GET /api/v1/im-channels/platforms serves the registered platforms, and
IMChannelPanel renders modes, links and credentials from it, dropping
the per-platform v-if blocks. WeChat keeps its QR binding flow; its
bound fields are declared hidden so the token is still treated as a
secret.

Fix IM channel edits wiping credentials: list responses stopped
carrying credentials in 7099a7e18, so the editor sent an empty map that
replaced the stored one. GET /api/v1/im-channels/:id (Admin) now returns
the channel with secrets redacted, and updates merge credentials key by
key, keeping secrets sent empty or as ***.
This commit is contained in:
wizardchen
2026-09-25 20:55:09 +08:00
parent 0a369050a9
commit 6a7e41f806
20 changed files with 910 additions and 432 deletions
+30
View File
@@ -1,3 +1,4 @@
import type { ConfigSchema } from '@/components/schema-form/schema'
import { get, post, put, del } from "../../utils/request";
// 智能体配置
@@ -347,6 +348,35 @@ export interface IMChannelOverview {
updated_at: string;
}
// IMPlatformInfo describes one IM platform (internal/im.PlatformInfo): its
// connection modes, console links and the credential form.
export interface IMPlatformInfo {
id: string
name: string
names?: Record<string, string>
order: number
/** Supported connection modes, default first. */
modes: Array<IMChannel['mode']>
mode_labels?: Record<string, string>
mode_hint_key?: string
supports_thread: boolean
links?: Array<{ url: string; title: string; title_key?: string }>
config_schema?: ConfigSchema
}
/**
* One channel for the editor, credentials included: plain fields as stored,
* set secrets as "***". Send them back unchanged to keep the stored secret.
*/
export function getIMChannel(id: string) {
return get<{ data: IMChannel }>(`/api/v1/im-channels/${id}`);
}
/** The IM platforms this deployment has adapters for, in display order. */
export function listIMPlatforms() {
return get<{ data: IMPlatformInfo[] }>('/api/v1/im-channels/platforms');
}
export function listAllIMChannels() {
return get<{ data: IMChannelOverview[] }>('/api/v1/im-channels');
}
+119 -377
View File
@@ -119,7 +119,7 @@
</template>
<t-option v-for="item in platformOptions" :key="item.value" :value="item.value" :label="item.label">
<div class="im-platform-select-option">
<img :src="item.logo" :alt="item.label" class="im-platform-select-option__icon" />
<img v-if="item.logo" :src="item.logo" :alt="item.label" class="im-platform-select-option__icon" />
<span>{{ item.label }}</span>
</div>
</t-option>
@@ -146,28 +146,19 @@
<!-- Step 2: Connection -->
<div v-else-if="wizardStep === 1" class="im-step-body">
<section v-if="formData.platform !== 'wechat'" class="setting-drawer__section im-drawer__section">
<section v-if="connectionModes.length > 0" class="setting-drawer__section im-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionAccess') }}</h4>
<div class="form-item">
<label class="form-label required">{{ $t('agentEditor.im.mode') }}</label>
<div class="option-chips">
<button type="button" class="option-chip"
:class="{ 'option-chip--active': formData.mode === 'websocket' }"
:disabled="formData.platform === 'mattermost'"
@click="formData.mode = 'websocket'">
{{ formData.platform === 'dingtalk' ? 'Stream' : 'WebSocket' }}
</button>
<button v-if="formData.platform !== 'dingtalk' && formData.platform !== 'qqbot'" type="button" class="option-chip" :class="{ 'option-chip--active': formData.mode === 'webhook' }"
@click="formData.mode = 'webhook'">
Webhook
<button v-for="mode in connectionModes" :key="mode" type="button" class="option-chip"
:class="{ 'option-chip--active': formData.mode === mode }"
@click="formData.mode = mode">
{{ modeLabel(mode) }}
</button>
</div>
<p class="form-desc">
{{ formData.platform === 'mattermost' ? $t('agentEditor.im.mattermostModeHint') :
formData.platform === 'yunzhijia' ? $t('agentEditor.im.yunzhijiaModeHint') :
$t('agentEditor.im.modeHint') }}
</p>
<p class="form-desc">{{ modeHint }}</p>
</div>
<div class="form-item">
@@ -249,292 +240,19 @@
<section class="setting-drawer__section im-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionCredentials') }}</h4>
<div class="drawer-form">
<!-- WeCom credentials -->
<template v-if="formData.platform === 'wecom'">
<div class="platform-link-hint">
<a href="https://work.weixin.qq.com/" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.wecomConsole') }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<template v-if="formData.mode === 'websocket'">
<div class="form-item">
<label class="form-label">Bot ID</label>
<t-input v-model="formData.credentials.bot_id" placeholder="Bot ID" />
</div>
<div class="form-item">
<label class="form-label">Bot Secret</label>
<t-input v-model="formData.credentials.bot_secret" type="password" placeholder="Bot Secret" />
</div>
<div class="form-item">
<label class="form-label">WebSocket Endpoint</label>
<t-input v-model="formData.credentials.ws_endpoint" placeholder="wss://openws.work.weixin.qq.com" />
<p class="form-desc">{{ $t('agentEditor.im.wecomWSEndpointHint') }}</p>
</div>
</template>
<template v-else>
<div class="form-item">
<label class="form-label">Corp ID</label>
<t-input v-model="formData.credentials.corp_id" placeholder="Corp ID" />
</div>
<div class="form-item">
<label class="form-label">Agent Secret</label>
<t-input v-model="formData.credentials.agent_secret" type="password" placeholder="Agent Secret" />
</div>
<div class="form-item">
<label class="form-label">Token</label>
<t-input v-model="formData.credentials.token" placeholder="Token" />
</div>
<div class="form-item">
<label class="form-label">EncodingAESKey</label>
<t-input v-model="formData.credentials.encoding_aes_key" placeholder="EncodingAESKey" />
</div>
<div class="form-item">
<label class="form-label">Corp Agent ID</label>
<t-input-number v-model="formData.credentials.corp_agent_id" placeholder="Corp Agent ID"
style="width: 100%;" />
</div>
<div class="form-item">
<label class="form-label">API Base URL</label>
<t-input v-model="formData.credentials.api_base_url" placeholder="https://qyapi.weixin.qq.com" />
<p class="form-desc">{{ $t('agentEditor.im.wecomAPIBaseURLHint') }}</p>
</div>
</template>
</template>
<div v-if="currentPlatform?.links?.length" class="platform-link-hint">
<a v-for="link in currentPlatform.links" :key="link.url" :href="link.url" target="_blank"
rel="noopener noreferrer" class="doc-link">
{{ link.title_key && te(link.title_key) ? t(link.title_key) : link.title }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<!-- Feishu / Lark credentials — same fields, different open platform -->
<template v-if="formData.platform === 'feishu' || formData.platform === 'lark'">
<div class="platform-link-hint">
<a :href="openPlatformConsole.url" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t(openPlatformConsole.labelKey) }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<div class="form-item">
<label class="form-label">App ID</label>
<t-input v-model="formData.credentials.app_id" placeholder="App ID" />
</div>
<div class="form-item">
<label class="form-label">App Secret</label>
<t-input v-model="formData.credentials.app_secret" type="password" placeholder="App Secret" />
</div>
<div class="form-item">
<label class="form-label">Base URL</label>
<t-input v-model="formData.credentials.api_base_url" placeholder="https://open.feishu.cn" />
<p class="form-desc">{{ $t('agentEditor.im.feishuAPIBaseURLHint') }}</p>
</div>
<template v-if="formData.mode === 'webhook'">
<div class="form-item">
<label class="form-label">Verification Token</label>
<t-input v-model="formData.credentials.verification_token" placeholder="Verification Token" />
</div>
<div class="form-item">
<label class="form-label">Encrypt Key</label>
<t-input v-model="formData.credentials.encrypt_key" type="password" placeholder="Encrypt Key" />
</div>
</template>
</template>
<!-- Slack credentials -->
<template v-if="formData.platform === 'slack'">
<div class="platform-link-hint">
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.slackConsole') }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<template v-if="formData.mode === 'websocket'">
<div class="form-item">
<label class="form-label">App Token</label>
<t-input v-model="formData.credentials.app_token" type="password" placeholder="xapp-..." />
</div>
<div class="form-item">
<label class="form-label">Bot Token</label>
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="xoxb-..." />
</div>
</template>
<template v-else>
<div class="form-item">
<label class="form-label">Bot Token</label>
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="xoxb-..." />
</div>
<div class="form-item">
<label class="form-label">Signing Secret</label>
<t-input v-model="formData.credentials.signing_secret" type="password" placeholder="Signing Secret" />
</div>
</template>
</template>
<!-- Telegram credentials -->
<template v-if="formData.platform === 'telegram'">
<div class="platform-link-hint">
<a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.telegramConsole') }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<div class="form-item">
<label class="form-label">Bot Token</label>
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="123456789:AABBccdd..." />
</div>
<template v-if="formData.mode === 'webhook'">
<div class="form-item">
<label class="form-label">Secret Token</label>
<t-input v-model="formData.credentials.secret_token" type="password"
placeholder="Secret Token (optional)" />
</div>
</template>
</template>
<!-- DingTalk credentials -->
<template v-if="formData.platform === 'dingtalk'">
<div class="platform-link-hint">
<a href="https://open.dingtalk.com/" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.dingtalkConsole') }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<div class="form-item">
<label class="form-label">Client ID (AppKey)</label>
<t-input v-model="formData.credentials.client_id" placeholder="Client ID / AppKey" />
</div>
<div class="form-item">
<label class="form-label">Client Secret (AppSecret)</label>
<t-input v-model="formData.credentials.client_secret" type="password"
placeholder="Client Secret / AppSecret" />
</div>
<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.dingtalkCardTemplateId') }}</label>
<t-input v-model="formData.credentials.card_template_id"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.schema" />
<p class="form-desc">{{ $t('agentEditor.im.dingtalkCardTemplateIdHint') }}</p>
</div>
</template>
<!-- QQBot credentials -->
<template v-if="formData.platform === 'qqbot'">
<div class="platform-link-hint">
<a href="https://q.qq.com/" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.qqbotConsole') }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<div class="form-item">
<label class="form-label">App ID</label>
<t-input v-model="formData.credentials.app_id" placeholder="QQBot App ID" />
</div>
<div class="form-item">
<label class="form-label">App Secret</label>
<t-input v-model="formData.credentials.client_secret" type="password" placeholder="QQBot App Secret" />
</div>
<div class="form-item">
<label class="form-label">API Base URL</label>
<t-input v-model="formData.credentials.api_base_url" placeholder="https://api.sgroup.qq.com" />
<p class="form-desc">{{ $t('agentEditor.im.qqbotAPIBaseURLHint') }}</p>
</div>
<div class="form-item">
<label class="form-label">Gateway URL</label>
<t-input v-model="formData.credentials.gateway_url" placeholder="wss://api.sgroup.qq.com/websocket/" />
<p class="form-desc">{{ $t('agentEditor.im.qqbotGatewayURLHint') }}</p>
</div>
</template>
<!-- Mattermost credentials -->
<template v-if="formData.platform === 'mattermost'">
<div class="platform-link-hint">
<a href="https://developers.mattermost.com/integrate/webhooks/outgoing/" target="_blank"
rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.mattermostConsole') }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<div class="form-item">
<label class="form-label">Site URL</label>
<t-input v-model="formData.credentials.site_url" placeholder="https://mattermost.example.com" />
</div>
<div class="form-item">
<label class="form-label">Bot Token</label>
<t-input v-model="formData.credentials.bot_token" type="password" placeholder="Bot Token" />
</div>
<div class="form-item">
<label class="form-label">Outgoing Webhook Token</label>
<t-input v-model="formData.credentials.outgoing_token" type="password"
placeholder="Token from Outgoing Webhook" />
</div>
<div class="form-item">
<label class="form-label">Bot User ID</label>
<t-input v-model="formData.credentials.bot_user_id" placeholder="Optional — filter bot self-messages" />
</div>
<div class="settings-group">
<div class="setting-row setting-row--last">
<div class="setting-info">
<label>{{ $t('agentEditor.im.mattermostPostToMain') }}</label>
<p class="desc">{{ $t('agentEditor.im.mattermostPostToMainHint') }}</p>
</div>
<div class="setting-control">
<t-switch :value="!!formData.credentials.post_to_main" size="small"
@change="(v: boolean) => { formData.credentials.post_to_main = v }" />
</div>
</div>
</div>
</template>
<!-- Yunzhijia credentials -->
<template v-if="formData.platform === 'yunzhijia'">
<div class="form-item">
<label class="form-label required">{{ $t('agentEditor.im.yunzhijiaSendMsgUrl') }}</label>
<t-input v-model="formData.credentials.send_msg_url"
placeholder="https://www.yunzhijia.com/gateway/robot/webhook/send?yzjtype=0&yzjtoken=..." />
<p class="form-desc">
{{ $t('agentEditor.im.yunzhijiaSendMsgUrlHint') }}
<a href="https://www.yunzhijia.com/opendocs/docs.html#/guide/im/robot" target="_blank"
rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.yunzhijiaRobotDoc') }}
</a>
</p>
</div>
<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.yunzhijiaSecret') }}</label>
<t-input v-model="formData.credentials.secret" type="password"
:placeholder="$t('agentEditor.im.yunzhijiaSecretPlaceholder')" />
<p class="form-desc">{{ $t('agentEditor.im.yunzhijiaSecretHint') }}</p>
</div>
<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.yunzhijiaAppId') }}</label>
<t-input v-model="formData.credentials.app_id"
:placeholder="$t('agentEditor.im.yunzhijiaAppIdPlaceholder')" />
<p class="form-desc">
{{ $t('agentEditor.im.yunzhijiaAppCredentialHint') }}
<a href="https://www.yunzhijia.com/developers/" target="_blank" rel="noopener noreferrer"
class="doc-link">
{{ $t('agentEditor.im.yunzhijiaImageDoc') }}
</a>
</p>
</div>
<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.yunzhijiaAppSecret') }}</label>
<t-input v-model="formData.credentials.app_secret" type="password"
:placeholder="$t('agentEditor.im.yunzhijiaAppSecretPlaceholder')" />
</div>
<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.yunzhijiaTimeout') }}</label>
<t-input-number v-model="formData.credentials.timeout_seconds" placeholder="10" :min="1" :max="60"
style="width: 100%;" />
<p class="form-desc">{{ $t('agentEditor.im.yunzhijiaTimeoutHint') }}</p>
</div>
<div class="form-item">
<label class="form-label required">{{ $t('agentEditor.im.yunzhijiaAllowedHostSuffix') }}</label>
<t-input v-model="formData.credentials.allowed_webhook_host_suffix" placeholder="yunzhijia.com" />
<p class="form-desc">{{ $t('agentEditor.im.yunzhijiaAllowedHostSuffixHint') }}</p>
</div>
</template>
<!-- Credentials from the platform's config schema; mode-specific fields follow formData.mode. -->
<SchemaForm v-if="currentPlatform?.config_schema" v-model="formData.credentials"
:schema="currentPlatform.config_schema" :context="{ mode: formData.mode }"
:errors="credentialErrors" />
<!-- WeChat credentials (QR code binding) -->
<template v-if="formData.platform === 'wechat'">
@@ -593,14 +311,17 @@ import { MessagePlugin } from 'tdesign-vue-next';
import { copyWithToast } from '@/utils/clipboard';
import { normalizeOptionalString } from '@/utils/optionalString';
import {
listIMChannels, createIMChannel, updateIMChannel, deleteIMChannel, toggleIMChannel,
getWeChatQRCode, pollWeChatQRCodeStatus, listAllIMChannels, listAgents,
type IMChannelOverview, type CustomAgent,
createIMChannel, updateIMChannel, deleteIMChannel, toggleIMChannel,
getWeChatQRCode, pollWeChatQRCodeStatus, listAllIMChannels, listAgents, listIMPlatforms, getIMChannel,
type IMChannelOverview, type CustomAgent, type IMPlatformInfo,
} from '@/api/agent';
import { useChatResourcesStore } from '@/stores/chatResources';
import type { IMChannel } from '@/api/agent';
import { useAuthStore } from '@/stores/auth';
import SettingDrawer from '@/components/settings/SettingDrawer.vue';
import SchemaForm from '@/components/schema-form/SchemaForm.vue';
import { applyDefaults, schemaAt, validateConfig, type FieldError } from '@/components/schema-form/schema';
import { useSchemaText } from '@/components/schema-form/useSchemaText';
import IntegrationsAgentFilter from '@/components/IntegrationsAgentFilter.vue';
import wecomLogo from '@/assets/img/im/wecom.svg';
import feishuLogo from '@/assets/img/im/feishu.svg';
@@ -631,8 +352,43 @@ const PLATFORM_LOGO: Record<string, string> = {
const platformLogo = (platform: string): string => (platform ? PLATFORM_LOGO[platform] || '' : '');
const { t } = useI18n();
const { t, te, locale } = useI18n();
const authStore = useAuthStore();
const schemaText = useSchemaText();
// Platforms with a registered adapter, from the backend: modes, links and
// the credential form of each.
const platforms = ref<IMPlatformInfo[]>([]);
async function loadPlatforms() {
try {
const res = await listIMPlatforms();
platforms.value = res.data || [];
} catch (e: any) {
MessagePlugin.error(e?.message || t('common.operationFailed'));
}
}
const currentPlatform = computed(() => platforms.value.find((p) => p.id === formData.value.platform));
// Modes offered as chips; long polling (WeChat) runs without a choice.
const connectionModes = computed(() =>
(currentPlatform.value?.modes || []).filter((m) => m !== 'longpoll'),
);
function modeLabel(mode: string): string {
const custom = currentPlatform.value?.mode_labels?.[mode];
if (custom) return custom;
return mode === 'webhook' ? 'Webhook' : 'WebSocket';
}
const modeHint = computed(() => {
const key = currentPlatform.value?.mode_hint_key;
return key && te(key) ? t(key) : t('agentEditor.im.modeHint');
});
// Field errors of the last save attempt, shown under each credential.
const credentialErrors = ref<FieldError[]>([]);
const filterAgentId = defineModel<string>('filterAgentId', { default: '' });
@@ -668,18 +424,9 @@ const drawerConfirmText = computed(() =>
wizardStep.value < stepTitles.value.length - 1 ? t('common.next') : t('common.save'),
);
const platformOptions = computed(() => ([
{ value: 'wecom' as IMPlatform, label: t('agentEditor.im.wecom'), logo: wecomLogo },
{ value: 'feishu' as IMPlatform, label: t('agentEditor.im.feishu'), logo: feishuLogo },
{ value: 'lark' as IMPlatform, label: t('agentEditor.im.lark'), logo: larkLogo },
{ value: 'slack' as IMPlatform, label: t('agentEditor.im.slack'), logo: slackLogo },
{ value: 'telegram' as IMPlatform, label: t('agentEditor.im.telegram'), logo: telegramLogo },
{ value: 'dingtalk' as IMPlatform, label: t('agentEditor.im.dingtalk'), logo: dingtalkLogo },
{ value: 'mattermost' as IMPlatform, label: t('agentEditor.im.mattermost'), logo: mattermostLogo },
{ value: 'wechat' as IMPlatform, label: t('agentEditor.im.wechat'), logo: wechatLogo },
{ value: 'qqbot' as IMPlatform, label: t('agentEditor.im.qqbot'), logo: qqbotLogo },
{ value: 'yunzhijia' as IMPlatform, label: t('agentEditor.im.yunzhijia'), logo: yunzhijiaLogo },
]));
const platformOptions = computed(() =>
platforms.value.map((p) => ({ value: p.id as IMPlatform, label: platformLabel(p.id), logo: platformLogo(p.id) })),
);
const localeOptions = computed(() => ([
{ value: '' as IMLocale, label: t('agentEditor.im.replyLanguageDefault') },
@@ -690,14 +437,6 @@ const localeOptions = computed(() => ([
{ value: 'ru-RU' as IMLocale, label: 'Русский' },
]));
// Feishu and Lark are the same product on separate clouds, so each has its own
// open platform console. Bots must be created on the one matching the channel.
const openPlatformConsole = computed(() =>
formData.value.platform === 'lark'
? { url: 'https://open.larksuite.com/', labelKey: 'agentEditor.im.larkConsole' }
: { url: 'https://open.feishu.cn/', labelKey: 'agentEditor.im.feishuConsole' },
);
const drawerTitle = computed(() => {
if (editingChannel.value) {
return formData.value.name?.trim() || t('agentEditor.im.unnamed');
@@ -787,7 +526,9 @@ function agentForChannel(channel: IMChannel | IMChannelOverview): CustomAgent |
function platformLabel(platform: string): string {
const key = `agentEditor.im.${platform}`;
return t(key);
if (te(key)) return t(key);
const info = platforms.value.find((p) => p.id === platform);
return info?.names?.[locale.value] || info?.name || platform;
}
function defaultChannelName(platform: string = formData.value.platform): string {
@@ -799,19 +540,15 @@ function resolvedChannelName(): string {
}
function platformSupportsThread(platform: string): boolean {
return ['slack', 'mattermost', 'feishu', 'lark', 'telegram', 'yunzhijia'].includes(platform);
return !!platforms.value.find((p) => p.id === platform)?.supports_thread;
}
watch(
() => formData.value.platform,
(p) => {
if (p === 'dingtalk' || p === 'qqbot') formData.value.mode = 'websocket';
if (p === 'mattermost') {
formData.value.mode = 'webhook';
if (typeof formData.value.credentials.post_to_main !== 'boolean') {
formData.value.credentials.post_to_main = false;
}
}
const modes = currentPlatform.value?.modes || [];
if (modes.length > 0 && !modes.includes(formData.value.mode)) formData.value.mode = modes[0];
applyCredentialDefaults();
if (!platformSupportsThread(p)) {
formData.value.session_mode = 'user';
}
@@ -828,43 +565,52 @@ const wechatBound = computed(() => {
function onPlatformChange(val: string | number | boolean) {
if (editingChannel.value) return;
formData.value.credentials = defaultCredentials();
credentialErrors.value = [];
stopWeChatPolling();
wechatQRContent.value = '';
wechatQRImgUrl.value = '';
wechatQRCode.value = '';
wechatQRStatus.value = '';
// WeChat uses fixed mode/output
if (val === 'wechat') {
formData.value.mode = 'longpoll';
formData.value.output_mode = 'full';
} else if (val === 'mattermost' || val === 'yunzhijia') {
formData.value.mode = 'webhook';
formData.value.output_mode = 'stream';
if (val === 'yunzhijia') {
formData.value.credentials = {
timeout_seconds: 10,
allowed_webhook_host_suffix: 'yunzhijia.com',
};
}
} else {
formData.value.mode = 'websocket';
formData.value.output_mode = 'stream';
}
// Each platform starts on its default mode; long polling (WeChat) only
// supports full output.
const info = platforms.value.find((p) => p.id === val);
formData.value.mode = info?.modes?.[0] || 'websocket';
formData.value.output_mode = formData.value.mode === 'longpoll' ? 'full' : 'stream';
applyCredentialDefaults();
if (!channelNameTouched.value) {
formData.value.name = defaultChannelName(String(val));
}
}
function normalizeYunzhijiaCredentials() {
if (formData.value.platform !== 'yunzhijia') return;
if (!formData.value.credentials.allowed_webhook_host_suffix) {
formData.value.credentials.allowed_webhook_host_suffix = 'yunzhijia.com';
}
if (!formData.value.credentials.timeout_seconds) {
formData.value.credentials.timeout_seconds = 10;
// Fills the platform's schema defaults (Yunzhijia's host suffix and timeout,
// Mattermost's post_to_main) into missing credentials.
function applyCredentialDefaults() {
const schema = currentPlatform.value?.config_schema;
if (!schema) return;
const filled = applyDefaults(schema, formData.value.credentials);
// Assign only on change: a no-op write would still trigger the watcher that
// clears the errors checkCredentials just set.
if (JSON.stringify(filled) !== JSON.stringify(formData.value.credentials)) {
formData.value.credentials = filled;
}
}
// Checks the credentials against the platform schema for the chosen mode,
// marking failing fields and warning with the first.
function checkCredentials(): boolean {
const schema = currentPlatform.value?.config_schema;
if (!schema) return true;
credentialErrors.value = validateConfig(schema, formData.value.credentials, {
context: { mode: formData.value.mode },
});
const first = credentialErrors.value[0];
if (!first) return true;
const field = schemaAt(schema, first.path);
const label = field ? schemaText(field, 'title') : first.path;
MessagePlugin.warning(`${label}: ${t(`schemaForm.errors.${first.code}`)}`);
return false;
}
async function startWeChatBinding() {
stopWeChatPolling();
wechatLoading.value = true;
@@ -978,16 +724,15 @@ function openDrawer(channel: IMChannel | IMChannelOverview) {
async function editChannel(channel: IMChannel | IMChannelOverview) {
wizardStep.value = 0;
// List responses carry no credentials; load the channel with its
// credentials (secrets redacted) so the form shows what is configured and a
// save does not send an empty credential map.
let fullChannel: IMChannel | null = null;
if (!('credentials' in channel)) {
try {
const res = await listIMChannels(channel.agent_id);
fullChannel = (res.data || []).find((item) => item.id === channel.id) || null;
} catch {
fullChannel = null;
}
} else {
fullChannel = channel as IMChannel;
try {
const res = await getIMChannel(channel.id);
fullChannel = res.data || null;
} catch {
fullChannel = null;
}
if (!fullChannel) {
MessagePlugin.error(t('common.operationFailed'));
@@ -1007,7 +752,8 @@ async function editChannel(channel: IMChannel | IMChannelOverview) {
knowledge_base_id: fullChannel.knowledge_base_id || '',
credentials: { ...fullChannel.credentials },
};
normalizeYunzhijiaCredentials();
applyCredentialDefaults();
credentialErrors.value = [];
showCreateDialog.value = true;
}
@@ -1042,15 +788,8 @@ async function handleSave() {
MessagePlugin.warning(t('agentEditor.im.wechatScanBind'));
return;
}
if (formData.value.platform === 'yunzhijia') {
// normalize fills in the default allowed host suffix, so only the send URL
// needs explicit validation here.
normalizeYunzhijiaCredentials();
if (!String(formData.value.credentials.send_msg_url || '').trim()) {
MessagePlugin.warning(t('agentEditor.im.yunzhijiaSendMsgUrlRequired'));
return;
}
}
applyCredentialDefaults();
if (!checkCredentials()) return;
if (editingChannel.value) {
await updateIMChannel(editingChannel.value.id, {
@@ -1115,8 +854,11 @@ async function handleDelete(id: string) {
onMounted(() => {
loadChannels();
loadPlatforms();
});
watch(() => formData.value.credentials, () => { credentialErrors.value = []; }, { deep: true });
watch(filterAgentId, (id) => {
if (!showCreateDialog.value && !editingChannel.value && id) {
formData.value.target_agent_id = id;
@@ -8,6 +8,7 @@
:secret-mode="secretMode"
:errors="errors"
:disabled="disabled"
:context="context"
@update:value="$emit('update:modelValue', $event)"
>
<template #secret="slotProps"><slot name="secret" v-bind="slotProps" /></template>
@@ -36,8 +37,10 @@ withDefaults(
secretMode?: 'input' | 'slot'
errors?: FieldError[]
disabled?: boolean
/** Values "$"-prefixed x-visible-if keys read, e.g. { mode } for IM credentials. */
context?: ConfigValue
}>(),
{ group: undefined, secretMode: 'input', errors: () => [], disabled: false },
{ group: undefined, secretMode: 'input', errors: () => [], disabled: false, context: undefined },
)
defineEmits<{ 'update:modelValue': [value: ConfigValue] }>()
@@ -9,6 +9,7 @@
:secret-mode="secretMode"
:errors="errors"
:disabled="disabled"
:context="context"
@update:value="set(field.key, $event)"
>
<template #secret="slotProps"><slot name="secret" v-bind="slotProps" /></template>
@@ -44,6 +45,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
HIDDEN_WIDGET,
inGroup,
isPlainObject,
isVisible,
@@ -66,6 +68,8 @@ const props = defineProps<{
secretMode: 'input' | 'slot'
errors?: FieldError[]
disabled?: boolean
/** Values "$"-prefixed x-visible-if keys read. */
context?: ConfigValue
}>()
const emit = defineEmits<{ 'update:value': [value: ConfigValue] }>()
@@ -77,7 +81,10 @@ const text = useSchemaText()
const fields = computed(() =>
orderedFields(props.schema).filter(
f => isVisible(f.schema, props.value) && (props.path !== '' || inGroup(f.schema, props.group)),
f =>
f.schema['x-widget'] !== HIDDEN_WIDGET &&
isVisible(f.schema, props.value, props.context) &&
(props.path !== '' || inGroup(f.schema, props.group)),
),
)
@@ -151,3 +151,15 @@ test('schemaAt follows dotted paths', () => {
assert.equal(schemaAt(zhipu, 'extra_config.missing'), undefined)
assert.equal(schemaAt(zhipu, 'api_key.deeper'), undefined)
})
test('"$" keys in x-visible-if read the context', () => {
const s: ConfigSchema = {
type: 'object',
required: ['signing_secret'],
properties: { signing_secret: { type: 'string', 'x-visible-if': { $mode: 'webhook' } } },
}
assert.equal(isVisible(s.properties!.signing_secret, {}, { mode: 'webhook' }), true)
assert.equal(isVisible(s.properties!.signing_secret, { mode: 'webhook' }, { mode: 'websocket' }), false)
assert.deepEqual(validateConfig(s, {}, { context: { mode: 'websocket' } }), [])
assert.deepEqual(validateConfig(s, {}, { context: { mode: 'webhook' } }), [{ path: 'signing_secret', code: 'required' }])
})
+20 -4
View File
@@ -49,6 +49,9 @@ export interface FieldError {
message?: string
}
/** x-widget value that keeps a declared field out of the form. */
export const HIDDEN_WIDGET = 'hidden'
/** Same value the backend sends for a set secret. */
export const REDACTED_SECRET = '***'
@@ -89,11 +92,22 @@ export function schemaAt(schema: ConfigSchema, path: string): ConfigSchema | und
return node
}
/** x-visible-if: every listed sibling must equal the given value. */
export function isVisible(schema: ConfigSchema, siblings: ConfigValue | undefined): boolean {
/**
* x-visible-if: every listed sibling must equal the given value. Keys that
* start with "$" ("$mode") are read from context instead: values of the object
* the configuration lives in, such as an IM channel's mode.
*/
export function isVisible(
schema: ConfigSchema,
siblings: ConfigValue | undefined,
context?: ConfigValue,
): boolean {
const cond = schema['x-visible-if']
if (!cond) return true
return Object.entries(cond).every(([key, want]) => looselyEqual(siblings?.[key], want))
return Object.entries(cond).every(([key, want]) => {
const got = key.startsWith('$') ? context?.[key.slice(1)] : siblings?.[key]
return looselyEqual(got, want)
})
}
/** Whether a field belongs to a group filter; no filter shows everything. */
@@ -165,6 +179,8 @@ export function secretPaths(schema: ConfigSchema, prefix = ''): string[] {
export interface ValidateOptions {
/** Skip secret fields entirely (they are edited elsewhere, e.g. /credentials). */
skipSecrets?: boolean
/** Values "$"-prefixed x-visible-if keys read (see isVisible). */
context?: ConfigValue
}
/**
@@ -190,7 +206,7 @@ function validateObject(
opts: ValidateOptions,
) {
for (const { key, schema: prop, required } of orderedFields(schema)) {
if (!isVisible(prop, value)) continue
if (!isVisible(prop, value, opts.context)) continue
if (opts.skipSecrets && prop['x-secret']) continue
const fieldPath = path ? `${path}.${key}` : key
const v = value[key]
+38 -3
View File
@@ -1,9 +1,44 @@
// Locale keys that builtin config schemas name in x-i18n-keys (web search
// provider parameters, data source credentials). Only the backend references
// them, so the i18n audit counts this list as used; otherwise pruning would
// Locale keys the backend names: x-i18n-keys in builtin config schemas (web
// search parameters, data source and IM credentials), IM platform link titles
// and mode hints. Only the backend references them, so the i18n audit counts this list as used; otherwise pruning would
// drop the labels. internal/plugin/builtin/i18n_keys_test.go keeps it in step
// with the backend, sorted.
export const BACKEND_SCHEMA_I18N_KEYS = [
'agentEditor.im.dingtalkCardTemplateId',
'agentEditor.im.dingtalkCardTemplateIdHint',
'agentEditor.im.dingtalkConsole',
'agentEditor.im.feishuAPIBaseURLHint',
'agentEditor.im.feishuConsole',
'agentEditor.im.larkConsole',
'agentEditor.im.mattermostConsole',
'agentEditor.im.mattermostModeHint',
'agentEditor.im.mattermostPostToMain',
'agentEditor.im.mattermostPostToMainHint',
'agentEditor.im.qqbotAPIBaseURLHint',
'agentEditor.im.qqbotConsole',
'agentEditor.im.qqbotGatewayURLHint',
'agentEditor.im.slackConsole',
'agentEditor.im.telegramConsole',
'agentEditor.im.wecomAPIBaseURLHint',
'agentEditor.im.wecomConsole',
'agentEditor.im.wecomWSEndpointHint',
'agentEditor.im.yunzhijiaAllowedHostSuffix',
'agentEditor.im.yunzhijiaAllowedHostSuffixHint',
'agentEditor.im.yunzhijiaAppCredentialHint',
'agentEditor.im.yunzhijiaAppId',
'agentEditor.im.yunzhijiaAppIdPlaceholder',
'agentEditor.im.yunzhijiaAppSecret',
'agentEditor.im.yunzhijiaAppSecretPlaceholder',
'agentEditor.im.yunzhijiaImageDoc',
'agentEditor.im.yunzhijiaModeHint',
'agentEditor.im.yunzhijiaRobotDoc',
'agentEditor.im.yunzhijiaSecret',
'agentEditor.im.yunzhijiaSecretHint',
'agentEditor.im.yunzhijiaSecretPlaceholder',
'agentEditor.im.yunzhijiaSendMsgUrl',
'agentEditor.im.yunzhijiaSendMsgUrlHint',
'agentEditor.im.yunzhijiaTimeout',
'agentEditor.im.yunzhijiaTimeoutHint',
'credential.inputPlaceholder',
'datasource.field.apiToken',
'datasource.field.appId',
+53 -1
View File
@@ -44,6 +44,50 @@ func NewIMHandler(imService *im.Service) *IMHandler {
}
}
// GetIMChannel godoc
// @Summary 获取 IM 渠道
// @Description 返回单个渠道供编辑:凭证中的密钥字段以 *** 代替
// @Tags IM
// @Produce json
// @Param id path string true "渠道 ID"
// @Success 200 {object} map[string]interface{}
// @Security Bearer
// @Security ApiKeyAuth
// @Router /im-channels/{id} [get]
func (h *IMHandler) GetIMChannel(c *gin.Context) {
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
channel, err := h.imService.GetChannelByIDAndTenant(c.Param("id"), tenantID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
creds, err := im.RedactCredentials(channel.Platform, channel.Credentials)
if err != nil {
creds = map[string]any{}
}
c.JSON(http.StatusOK, gin.H{"data": struct {
*im.IMChannel
Credentials map[string]any `json:"credentials"`
}{channel, creds}})
}
// ListIMPlatforms godoc
// @Summary 列出 IM 平台
// @Description 返回已注册的 IM 平台:支持的接入模式、控制台链接和凭证表单 Schema
// @Tags IM
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Security Bearer
// @Security ApiKeyAuth
// @Router /im-channels/platforms [get]
func (h *IMHandler) ListIMPlatforms(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": h.imService.PlatformInfos()})
}
// ── Channel CRUD handlers ──
// CreateIMChannel creates a new IM channel for an agent.
@@ -265,7 +309,15 @@ func (h *IMHandler) UpdateIMChannel(c *gin.Context) {
}
}
if req.Credentials != nil {
channel.Credentials = req.Credentials
// The editor loads credentials redacted (GetIMChannel), so merge
// rather than replace: omitted keys and unchanged secrets keep their
// stored values.
merged, err := im.MergeCredentials(channel.Platform, channel.Credentials, req.Credentials)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid credentials"})
return
}
channel.Credentials = merged
}
if req.Enabled != nil {
channel.Enabled = *req.Enabled
+61
View File
@@ -3,6 +3,8 @@ package im
import (
"encoding/json"
"strings"
"github.com/Tencent/WeKnora/internal/plugin/configschema"
)
// ParseCredentials parses the JSONB credentials field into a map.
@@ -47,3 +49,62 @@ func GetBool(creds map[string]any, key string) bool {
return false
}
}
// secretKeys returns the credential keys a platform's schema marks secret.
func secretKeys(platform string) map[string]bool {
out := map[string]bool{}
if s := LookupPlatformInfo(platform).ConfigSchema; s != nil {
for key, prop := range s.Properties {
if prop.Secret {
out[key] = true
}
}
}
return out
}
// RedactCredentials returns a channel's credentials for the editor: plain
// fields as stored, set secrets replaced by configschema.RedactedPlaceholder.
func RedactCredentials(platform string, raw []byte) (map[string]any, error) {
creds, err := ParseCredentials(raw)
if err != nil {
return nil, err
}
secrets := secretKeys(platform)
for key, v := range creds {
if s, ok := v.(string); ok && s != "" && secrets[key] {
creds[key] = configschema.RedactedPlaceholder
}
}
return creds, nil
}
// MergeCredentials applies credentials sent by the editor to the stored ones.
// Keys the update leaves out keep their stored value, and so does a secret
// sent empty or as the redaction placeholder, since the editor never saw it.
// A plain field sent empty is cleared. An empty update changes nothing, which
// keeps an edit that never loaded the credentials from wiping them.
func MergeCredentials(platform string, stored, incoming []byte) ([]byte, error) {
update, err := ParseCredentials(incoming)
if err != nil {
return nil, err
}
if len(update) == 0 {
return stored, nil
}
out, err := ParseCredentials(stored)
if err != nil {
// Unreadable stored credentials cannot be merged into; take the update.
out = map[string]any{}
}
secrets := secretKeys(platform)
for key, v := range update {
if secrets[key] {
if s, ok := v.(string); v == nil || (ok && (s == "" || s == configschema.RedactedPlaceholder)) {
continue
}
}
out[key] = v
}
return json.Marshal(out)
}
+64
View File
@@ -0,0 +1,64 @@
package im
import (
"encoding/json"
"testing"
)
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
func TestRedactCredentialsHidesOnlySecrets(t *testing.T) {
stored := mustJSON(t, map[string]any{"site_url": "https://mm", "bot_token": "tok", "post_to_main": true})
got, err := RedactCredentials("mattermost", stored)
if err != nil {
t.Fatal(err)
}
if got["site_url"] != "https://mm" || got["bot_token"] != "***" || got["post_to_main"] != true {
t.Fatalf("redacted = %v", got)
}
}
func TestMergeCredentials(t *testing.T) {
stored := mustJSON(t, map[string]any{
"site_url": "https://mm", "bot_token": "tok", "outgoing_token": "out", "bot_user_id": "u1",
})
merge := func(update map[string]any) map[string]any {
t.Helper()
out, err := MergeCredentials("mattermost", stored, mustJSON(t, update))
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
t.Fatal(err)
}
return m
}
// An edit that never loaded the credentials must not wipe them.
if got := merge(map[string]any{}); got["bot_token"] != "tok" || got["site_url"] != "https://mm" {
t.Fatalf("empty update changed credentials: %v", got)
}
// The redacted form comes back with one plain field changed.
got := merge(map[string]any{
"site_url": "https://new", "bot_token": "***", "outgoing_token": "", "bot_user_id": "",
})
want := map[string]any{"site_url": "https://new", "bot_token": "tok", "outgoing_token": "out", "bot_user_id": ""}
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %v, want %v (all: %v)", k, got[k], v, got)
}
}
// A new secret replaces the stored one.
got = merge(map[string]any{"bot_token": "new-tok"})
if got["bot_token"] != "new-tok" || got["outgoing_token"] != "out" {
t.Fatalf("secret replacement = %v", got)
}
}
+316 -25
View File
@@ -1,40 +1,73 @@
package im
import "sort"
import (
"sort"
// PlatformInfo is the display metadata of one IM platform.
"github.com/Tencent/WeKnora/internal/plugin/configschema"
)
// Channel modes. The first mode a platform lists is its default.
const (
ModeWebSocket = "websocket"
ModeWebhook = "webhook"
ModeLongPoll = "longpoll"
)
// PlatformInfo describes one IM platform for the channel editor: how it is
// named, which connection modes it supports and what credentials it takes.
type PlatformInfo struct {
ID string
ID string `json:"id"`
// Name is the default (English) name; Names holds localized variants.
Name string
Names map[string]string
Name string `json:"name"`
Names map[string]string `json:"names,omitempty"`
// Order sorts platforms in the UI (lower first).
Order int
Order int `json:"order"`
// Modes lists the supported connection modes, default first.
Modes []string `json:"modes"`
// ModeLabels renames a mode for this platform (DingTalk calls its
// WebSocket connection "Stream").
ModeLabels map[string]string `json:"mode_labels,omitempty"`
// ModeHintKey is the frontend locale key of the hint under the mode
// choice; empty uses the generic hint.
ModeHintKey string `json:"mode_hint_key,omitempty"`
// SupportsThread reports whether replies can stay in a thread, which
// enables the per-thread session mode.
SupportsThread bool `json:"supports_thread"`
// Links point at the platform's console and docs.
Links []PlatformLink `json:"links,omitempty"`
// ConfigSchema describes IMChannel.Credentials. Fields that apply to one
// connection mode carry x-visible-if on "$mode", read from the channel
// rather than from the credentials.
ConfigSchema *configschema.Schema `json:"config_schema,omitempty"`
}
// platformInfos lists every platform that has an adapter. Names follow the
// frontend's integrations.im.* labels.
var platformInfos = map[string]PlatformInfo{
"wecom": {ID: "wecom", Name: "WeCom", Names: map[string]string{"zh-CN": "企业微信"}, Order: 10},
"feishu": {ID: "feishu", Name: "Feishu", Names: map[string]string{"zh-CN": "飞书"}, Order: 20},
"lark": {ID: "lark", Name: "Lark", Names: map[string]string{"zh-CN": "Lark(飞书国际版)"}, Order: 30},
"dingtalk": {ID: "dingtalk", Name: "DingTalk", Names: map[string]string{"zh-CN": "钉钉"}, Order: 40},
"wechat": {ID: "wechat", Name: "WeChat", Names: map[string]string{"zh-CN": "微信"}, Order: 50},
"qqbot": {ID: "qqbot", Name: "QQBot", Order: 60},
"yunzhijia": {ID: "yunzhijia", Name: "Yunzhijia", Names: map[string]string{"zh-CN": "云之家"}, Order: 70},
"slack": {ID: "slack", Name: "Slack", Order: 80},
"telegram": {ID: "telegram", Name: "Telegram", Order: 90},
"mattermost": {ID: "mattermost", Name: "Mattermost", Order: 100},
// PlatformLink is a link shown above the credential form.
type PlatformLink struct {
URL string `json:"url"`
Title string `json:"title"`
// TitleKey is the frontend locale key of the title.
TitleKey string `json:"title_key,omitempty"`
}
// LookupPlatformInfo returns the display metadata of a platform. A platform
// without an entry gets its ID as name, so a newly registered adapter still
// shows up.
// LookupPlatformInfo returns the metadata of a platform. A platform without
// an entry gets its ID as name and WebSocket as its only mode, so a newly
// registered adapter still shows up.
func LookupPlatformInfo(id string) PlatformInfo {
if info, ok := platformInfos[id]; ok {
return info
if build, ok := platformInfos[id]; ok {
return build()
}
return PlatformInfo{ID: id, Name: id, Order: 1000}
return PlatformInfo{ID: id, Name: id, Order: 1000, Modes: []string{ModeWebSocket}}
}
// KnownPlatformInfos returns the metadata of every platform this build
// describes, whether or not its adapter is registered, sorted by ID.
func KnownPlatformInfos() []PlatformInfo {
out := make([]PlatformInfo, 0, len(platformInfos))
for _, build := range platformInfos {
out = append(out, build())
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Platforms returns the platforms that have a registered adapter factory,
@@ -49,3 +82,261 @@ func (s *Service) Platforms() []string {
sort.Strings(out)
return out
}
// PlatformInfos returns the metadata of every registered platform in display
// order.
func (s *Service) PlatformInfos() []PlatformInfo {
ids := s.Platforms()
out := make([]PlatformInfo, 0, len(ids))
for _, id := range ids {
out = append(out, LookupPlatformInfo(id))
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Order < out[j].Order })
return out
}
// platformInfos builds each platform's metadata. Names follow the frontend's
// agentEditor.im.* labels; field hints reuse its locale keys.
var platformInfos = map[string]func() PlatformInfo{
"wecom": func() PlatformInfo {
wsEndpoint := text("WebSocket Endpoint", "wss://openws.work.weixin.qq.com", 3)
describe(wsEndpoint, "Optional. Custom WebSocket address for private WeCom deployments.",
"agentEditor.im.wecomWSEndpointHint")
apiBase := text("API Base URL", "https://qyapi.weixin.qq.com", 16)
describe(apiBase, "Optional. Custom API base URL for private WeCom deployments.",
"agentEditor.im.wecomAPIBaseURLHint")
agentID := &configschema.Schema{Type: configschema.TypeInteger, Title: "Corp Agent ID", Order: 15}
return PlatformInfo{
ID: "wecom", Name: "WeCom", Names: map[string]string{"zh-CN": "企业微信"}, Order: 10,
Modes: []string{ModeWebSocket, ModeWebhook},
Links: []PlatformLink{console("https://work.weixin.qq.com/", "WeCom Admin Console", "wecomConsole")},
ConfigSchema: configschema.Object().
Set("bot_id", onlyIn(ModeWebSocket, text("Bot ID", "Bot ID", 1)), false).
Set("bot_secret", onlyIn(ModeWebSocket, secret("Bot Secret", "Bot Secret", 2)), false).
Set("ws_endpoint", onlyIn(ModeWebSocket, wsEndpoint), false).
Set("corp_id", onlyIn(ModeWebhook, text("Corp ID", "Corp ID", 11)), false).
Set("agent_secret", onlyIn(ModeWebhook, secret("Agent Secret", "Agent Secret", 12)), false).
Set("token", onlyIn(ModeWebhook, text("Token", "Token", 13)), false).
Set("encoding_aes_key", onlyIn(ModeWebhook, text("EncodingAESKey", "EncodingAESKey", 14)), false).
Set("corp_agent_id", onlyIn(ModeWebhook, agentID), false).
Set("api_base_url", onlyIn(ModeWebhook, apiBase), false),
}
},
"feishu": func() PlatformInfo {
return feishuPlatform("feishu", "Feishu", "飞书", 20,
console("https://open.feishu.cn/", "Feishu Open Platform", "feishuConsole"))
},
// Lark is Feishu's international cloud: same credentials, separate console.
"lark": func() PlatformInfo {
return feishuPlatform("lark", "Lark", "Lark(飞书国际版)", 30,
console("https://open.larksuite.com/", "Lark Open Platform", "larkConsole"))
},
"dingtalk": func() PlatformInfo {
card := text("Card Template ID (optional)", "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.schema", 3)
card.I18nKeys = map[string]string{"title": "agentEditor.im.dingtalkCardTemplateId"}
describe(card, "Create an AI Card template to stream replies with a typewriter effect.",
"agentEditor.im.dingtalkCardTemplateIdHint")
return PlatformInfo{
ID: "dingtalk", Name: "DingTalk", Names: map[string]string{"zh-CN": "钉钉"}, Order: 60,
Modes: []string{ModeWebSocket},
ModeLabels: map[string]string{ModeWebSocket: "Stream"},
Links: []PlatformLink{
console("https://open.dingtalk.com/", "DingTalk Open Platform", "dingtalkConsole"),
},
ConfigSchema: configschema.Object().
Set("client_id", text("Client ID (AppKey)", "Client ID / AppKey", 1), false).
Set("client_secret", secret("Client Secret (AppSecret)", "Client Secret / AppSecret", 2), false).
Set("card_template_id", card, false),
}
},
// WeChat binds by scanning a QR code; the editor runs that flow itself
// and there is nothing to type. The bound fields are declared hidden so
// the token is still redacted and kept like any other secret.
"wechat": func() PlatformInfo {
hidden := func(title string, secret bool) *configschema.Schema {
return &configschema.Schema{Type: configschema.TypeString, Title: title, Widget: "hidden", Secret: secret}
}
return PlatformInfo{
ID: "wechat", Name: "WeChat", Names: map[string]string{"zh-CN": "微信"}, Order: 80,
Modes: []string{ModeLongPoll},
ConfigSchema: configschema.Object().
Set("bot_token", hidden("Bot Token", true), false).
Set("ilink_bot_id", hidden("iLink Bot ID", false), false).
Set("ilink_user_id", hidden("iLink User ID", false), false),
}
},
"qqbot": func() PlatformInfo {
apiBase := text("API Base URL", "https://api.sgroup.qq.com", 3)
describe(apiBase, "Optional. Leave empty to use the default production API endpoint.",
"agentEditor.im.qqbotAPIBaseURLHint")
gateway := text("Gateway URL", "wss://api.sgroup.qq.com/websocket/", 4)
describe(gateway, "Optional. Leave empty to fetch the common WebSocket gateway automatically.",
"agentEditor.im.qqbotGatewayURLHint")
return PlatformInfo{
ID: "qqbot", Name: "QQBot", Order: 90,
Modes: []string{ModeWebSocket},
Links: []PlatformLink{console("https://q.qq.com/", "QQ Open Platform", "qqbotConsole")},
ConfigSchema: configschema.Object().
Set("app_id", text("App ID", "QQBot App ID", 1), false).
Set("client_secret", secret("App Secret", "QQBot App Secret", 2), false).
Set("api_base_url", apiBase, false).
Set("gateway_url", gateway, false),
}
},
"yunzhijia": yunzhijiaPlatform,
"slack": func() PlatformInfo {
return PlatformInfo{
ID: "slack", Name: "Slack", Order: 40,
Modes: []string{ModeWebSocket, ModeWebhook},
SupportsThread: true,
Links: []PlatformLink{console("https://api.slack.com/apps", "Slack API Console", "slackConsole")},
ConfigSchema: configschema.Object().
Set("app_token", onlyIn(ModeWebSocket, secret("App Token", "xapp-...", 1)), false).
Set("bot_token", secret("Bot Token", "xoxb-...", 2), false).
Set("signing_secret", onlyIn(ModeWebhook, secret("Signing Secret", "Signing Secret", 3)), false),
}
},
"telegram": func() PlatformInfo {
return PlatformInfo{
ID: "telegram", Name: "Telegram", Order: 50,
Modes: []string{ModeWebSocket, ModeWebhook},
SupportsThread: true,
Links: []PlatformLink{console("https://t.me/BotFather", "Telegram BotFather", "telegramConsole")},
ConfigSchema: configschema.Object().
Set("bot_token", secret("Bot Token", "123456789:AABBccdd...", 1), false).
Set("secret_token", onlyIn(ModeWebhook, secret("Secret Token", "Secret Token (optional)", 2)), false),
}
},
"mattermost": func() PlatformInfo {
postToMain := &configschema.Schema{
Type: configschema.TypeBoolean,
Title: "Post replies in channel timeline",
Default: false,
Order: 5,
}
postToMain.I18nKeys = map[string]string{"title": "agentEditor.im.mattermostPostToMain"}
describe(postToMain, "When on, bot replies are new top-level posts instead of thread replies.",
"agentEditor.im.mattermostPostToMainHint")
return PlatformInfo{
ID: "mattermost", Name: "Mattermost", Order: 70,
Modes: []string{ModeWebhook},
ModeHintKey: "agentEditor.im.mattermostModeHint",
SupportsThread: true,
Links: []PlatformLink{console(
"https://developers.mattermost.com/integrate/webhooks/outgoing/",
"Mattermost integrations", "mattermostConsole",
)},
ConfigSchema: configschema.Object().
Set("site_url", text("Site URL", "https://mattermost.example.com", 1), false).
Set("bot_token", secret("Bot Token", "Bot Token", 2), false).
Set("outgoing_token", secret("Outgoing Webhook Token", "Token from Outgoing Webhook", 3), false).
Set("bot_user_id", text("Bot User ID", "Optional — filter bot self-messages", 4), false).
Set("post_to_main", postToMain, false),
}
},
}
func feishuPlatform(id, name, zhName string, order int, link PlatformLink) PlatformInfo {
apiBase := text("Base URL", "https://open.feishu.cn", 3)
describe(apiBase, "Optional. Enter a reverse proxy URL if the server reaches Feishu through one.",
"agentEditor.im.feishuAPIBaseURLHint")
return PlatformInfo{
ID: id, Name: name, Names: map[string]string{"zh-CN": zhName}, Order: order,
Modes: []string{ModeWebSocket, ModeWebhook},
SupportsThread: true,
Links: []PlatformLink{link},
ConfigSchema: configschema.Object().
Set("app_id", text("App ID", "App ID", 1), false).
Set("app_secret", secret("App Secret", "App Secret", 2), false).
Set("api_base_url", apiBase, false).
Set("verification_token", onlyIn(ModeWebhook, text("Verification Token", "Verification Token", 4)), false).
Set("encrypt_key", onlyIn(ModeWebhook, secret("Encrypt Key", "Encrypt Key", 5)), false),
}
}
func yunzhijiaPlatform() PlatformInfo {
sendURL := text("Send Message URL",
"https://www.yunzhijia.com/gateway/robot/webhook/send?yzjtype=0&yzjtoken=...", 1)
sendURL.I18nKeys = map[string]string{"title": "agentEditor.im.yunzhijiaSendMsgUrl"}
describe(sendURL, "Used for robot replies.", "agentEditor.im.yunzhijiaSendMsgUrlHint")
sign := secret("Signature Secret (optional)", "HmacSHA1 signature secret from Yunzhijia robot settings", 2)
localize(sign, "agentEditor.im.yunzhijiaSecret", "agentEditor.im.yunzhijiaSecretPlaceholder")
describe(sign, "If set, incoming callbacks are verified with an HmacSHA1 signature.",
"agentEditor.im.yunzhijiaSecretHint")
appID := text("App ID (image download)", "Yunzhijia Open Platform App ID", 3)
localize(appID, "agentEditor.im.yunzhijiaAppId", "agentEditor.im.yunzhijiaAppIdPlaceholder")
describe(appID, "Used to obtain appAccessToken and download images users send.",
"agentEditor.im.yunzhijiaAppCredentialHint")
appSecret := secret("App Secret (image download)", "Yunzhijia Open Platform App Secret", 4)
localize(appSecret, "agentEditor.im.yunzhijiaAppSecret", "agentEditor.im.yunzhijiaAppSecretPlaceholder")
minTimeout, maxTimeout := 1.0, 60.0
timeout := &configschema.Schema{
Type: configschema.TypeInteger, Title: "HTTP Timeout (seconds)", Default: 10,
Minimum: &minTimeout, Maximum: &maxTimeout, Placeholder: "10", Order: 5,
I18nKeys: map[string]string{"title": "agentEditor.im.yunzhijiaTimeout"},
}
describe(timeout, "Timeout for sending replies via Send Message URL.", "agentEditor.im.yunzhijiaTimeoutHint")
suffix := text("Allowed Host Suffix", "yunzhijia.com", 6)
suffix.Default = "yunzhijia.com"
suffix.I18nKeys = map[string]string{"title": "agentEditor.im.yunzhijiaAllowedHostSuffix"}
describe(suffix, "Restrict the Send Message URL host to this suffix.",
"agentEditor.im.yunzhijiaAllowedHostSuffixHint")
return PlatformInfo{
ID: "yunzhijia", Name: "Yunzhijia", Names: map[string]string{"zh-CN": "云之家"}, Order: 100,
Modes: []string{ModeWebhook, ModeWebSocket},
ModeHintKey: "agentEditor.im.yunzhijiaModeHint",
SupportsThread: true,
Links: []PlatformLink{
console("https://www.yunzhijia.com/opendocs/docs.html#/guide/im/robot",
"Robot messaging docs", "yunzhijiaRobotDoc"),
console("https://www.yunzhijia.com/developers/", "Developer platform (images)", "yunzhijiaImageDoc"),
},
ConfigSchema: configschema.Object().
Set("send_msg_url", sendURL, true).
Set("secret", sign, false).
Set("app_id", appID, false).
Set("app_secret", appSecret, false).
Set("timeout_seconds", timeout, false).
Set("allowed_webhook_host_suffix", suffix, true),
}
}
func text(title, placeholder string, order int) *configschema.Schema {
return &configschema.Schema{Type: configschema.TypeString, Title: title, Placeholder: placeholder, Order: order}
}
// secret is a password input. IM credentials are returned to admins as
// stored, so the form shows the saved value masked rather than redacted.
func secret(title, placeholder string, order int) *configschema.Schema {
s := text(title, placeholder, order)
s.Secret = true
s.Widget = "password"
return s
}
func onlyIn(mode string, s *configschema.Schema) *configschema.Schema {
s.VisibleIf = map[string]any{"$mode": mode}
return s
}
func describe(s *configschema.Schema, description, key string) {
s.Description = description
if s.I18nKeys == nil {
s.I18nKeys = map[string]string{}
}
s.I18nKeys["description"] = key
}
func localize(s *configschema.Schema, titleKey, placeholderKey string) {
s.I18nKeys = map[string]string{"title": titleKey, "placeholder": placeholderKey}
}
func console(url, title, key string) PlatformLink {
return PlatformLink{URL: url, Title: title, TitleKey: "agentEditor.im." + key}
}
+87
View File
@@ -0,0 +1,87 @@
package im
import (
"testing"
"github.com/Tencent/WeKnora/internal/plugin/configschema"
)
// registeredPlatforms are the adapters container.registerIMService wires.
var registeredPlatforms = []string{
"wecom", "feishu", "lark", "slack", "telegram", "dingtalk", "mattermost", "wechat", "qqbot", "yunzhijia",
}
func TestEveryPlatformHasValidMetadata(t *testing.T) {
for _, id := range registeredPlatforms {
info := LookupPlatformInfo(id)
if info.Order >= 1000 {
t.Errorf("%s: no metadata entry", id)
continue
}
if len(info.Modes) == 0 {
t.Errorf("%s: no connection mode", id)
}
if info.ConfigSchema == nil {
t.Errorf("%s: no config schema", id)
continue
}
if err := info.ConfigSchema.Check(); err != nil {
t.Errorf("%s: invalid schema: %v", id, err)
}
// Mode-dependent fields may only name modes the platform supports.
for key, prop := range info.ConfigSchema.Properties {
if mode, ok := prop.VisibleIf["$mode"]; ok && !contains(info.Modes, mode.(string)) {
t.Errorf("%s.%s: visible only in unsupported mode %q", id, key, mode)
}
}
}
}
func TestWeComFieldsFollowTheMode(t *testing.T) {
s := LookupPlatformInfo("wecom").ConfigSchema
visibleIn := func(mode string) map[string]bool {
out := map[string]bool{}
for key, prop := range s.Properties {
if m, ok := prop.VisibleIf["$mode"]; !ok || m == mode {
out[key] = true
}
}
return out
}
ws, hook := visibleIn(ModeWebSocket), visibleIn(ModeWebhook)
if !ws["bot_id"] || ws["corp_id"] || !hook["corp_id"] || hook["bot_secret"] {
t.Fatalf("websocket=%v webhook=%v", ws, hook)
}
}
func TestYunzhijiaRequiresTheSendURL(t *testing.T) {
s := LookupPlatformInfo("yunzhijia").ConfigSchema
errs := configschema.ValidateInContext(s, map[string]any{
"allowed_webhook_host_suffix": "yunzhijia.com", "timeout_seconds": 10.0,
}, map[string]any{"mode": ModeWebhook})
if len(errs) != 1 || errs[0].Path != "send_msg_url" || errs[0].Code != configschema.CodeRequired {
t.Fatalf("want send_msg_url required, got %v", errs)
}
errs = configschema.Validate(s, map[string]any{
"send_msg_url": "https://x", "allowed_webhook_host_suffix": "yunzhijia.com", "timeout_seconds": 99.0,
})
if len(errs) != 1 || errs[0].Code != configschema.CodeMaximum {
t.Fatalf("want timeout maximum error, got %v", errs)
}
}
func TestUnknownPlatformFallsBack(t *testing.T) {
info := LookupPlatformInfo("brand-new")
if info.Name != "brand-new" || len(info.Modes) != 1 {
t.Fatalf("fallback = %+v", info)
}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
+15 -2
View File
@@ -9,12 +9,14 @@ import (
"testing"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/plugin/configschema"
"github.com/Tencent/WeKnora/internal/types"
)
// backendSchemaKeysFile lists the frontend locale keys builtin config schemas
// name in x-i18n-keys. The frontend i18n audit treats them as used, so pruning
// backendSchemaKeysFile lists the frontend locale keys the backend names:
// x-i18n-keys in builtin config schemas, IM platform link titles and mode
// hints. The frontend i18n audit treats them as used, so pruning
// never drops a label only the backend references.
const backendSchemaKeysFile = "../../../frontend/src/i18n/backendSchemaKeys.ts"
@@ -42,6 +44,17 @@ func TestBackendSchemaKeysAreListedForTheFrontend(t *testing.T) {
for _, meta := range datasource.ListAvailableConnectors() {
collectI18nKeys(meta.ConfigSchema, used)
}
for _, info := range im.KnownPlatformInfos() {
collectI18nKeys(info.ConfigSchema, used)
for _, link := range info.Links {
if link.TitleKey != "" {
used[link.TitleKey] = true
}
}
if info.ModeHintKey != "" {
used[info.ModeHintKey] = true
}
}
data, err := os.ReadFile(filepath.FromSlash(backendSchemaKeysFile))
if err != nil {
@@ -244,3 +244,21 @@ func TestOpenWithoutKey(t *testing.T) {
t.Fatalf("legacy plaintext = %v, %v", legacy, err)
}
}
func TestValidateInContextReadsDollarKeysFromContext(t *testing.T) {
s := Object().
Set("bot_token", &Schema{Type: TypeString}, true).
Set("signing_secret", &Schema{Type: TypeString, VisibleIf: map[string]any{"$mode": "webhook"}}, true)
value := map[string]any{"bot_token": "x"}
if errs := ValidateInContext(s, value, map[string]any{"mode": "websocket"}); errs != nil {
t.Fatalf("websocket mode hides the webhook secret: %v", errs)
}
errs := ValidateInContext(s, value, map[string]any{"mode": "webhook"})
if len(errs) != 1 || errs[0].Path != "signing_secret" {
t.Fatalf("webhook mode requires the signing secret, got %v", errs)
}
// Without a context the field is hidden, never wrongly required.
if errs := Validate(s, value); errs != nil {
t.Fatalf("Validate without context: %v", errs)
}
}
+3 -2
View File
@@ -55,8 +55,9 @@ type Schema struct {
// responses, and kept when an update sends the redaction back.
Secret bool `json:"x-secret,omitempty"`
// Widget picks the form control (password, textarea, select, switch,
// tags, code, kv, model-select, kb-select, ...). Empty means the
// frontend picks from the type.
// headers, ...). Empty means the frontend picks from the type; "hidden"
// keeps a field out of the form while still declaring it (a secret set
// by another flow, such as WeChat's QR binding).
Widget string `json:"x-widget,omitempty"`
Placeholder string `json:"x-placeholder,omitempty"`
// I18n carries localized title / description / placeholder:
+34 -13
View File
@@ -49,49 +49,70 @@ func (e FieldErrors) Error() string {
// configuration written by a newer version still validates. A required field
// hidden by x-visible-if is not required.
func Validate(s *Schema, value map[string]any) FieldErrors {
return ValidateInContext(s, value, nil)
}
// ValidateInContext is Validate for a configuration that lives inside a larger
// object: x-visible-if keys starting with "$" ("$mode") are read from ctx
// (without the "$") instead of from sibling fields. An IM channel's
// credentials depend on the channel's mode this way.
func ValidateInContext(s *Schema, value, ctx map[string]any) FieldErrors {
var errs FieldErrors
validateObject(s, value, "", &errs)
v := &validator{ctx: ctx, errs: &errs}
v.object(s, value, "")
if len(errs) == 0 {
return nil
}
return errs
}
func validateObject(s *Schema, value map[string]any, path string, errs *FieldErrors) {
// validator carries the context through one validation.
type validator struct {
ctx map[string]any
errs *FieldErrors
}
func (vd *validator) object(s *Schema, value map[string]any, path string) {
errs := vd.errs
required := make(map[string]bool, len(s.Required))
for _, k := range s.Required {
required[k] = true
}
for _, key := range s.OrderedKeys() {
prop := s.Properties[key]
if !visible(prop, value) {
if !visible(prop, value, vd.ctx) {
continue
}
fieldPath := joinPath(path, key)
v, present := value[key]
if !present || v == nil || v == "" {
val, present := value[key]
if !present || val == nil || val == "" {
if required[key] {
*errs = append(*errs, FieldError{fieldPath, CodeRequired, "is required"})
}
continue
}
validateValue(prop, v, fieldPath, errs)
vd.value(prop, val, fieldPath)
}
}
// visible evaluates x-visible-if against sibling values.
func visible(s *Schema, siblings map[string]any) bool {
// visible evaluates x-visible-if against sibling values, or against ctx for
// "$"-prefixed keys.
func visible(s *Schema, siblings, ctx map[string]any) bool {
for key, want := range s.VisibleIf {
if !looselyEqual(siblings[key], want) {
got := siblings[key]
if name, fromCtx := strings.CutPrefix(key, "$"); fromCtx {
got = ctx[name]
}
if !looselyEqual(got, want) {
return false
}
}
return true
}
func validateValue(s *Schema, v any, path string, errs *FieldErrors) {
func (vd *validator) value(s *Schema, v any, path string) {
add := func(code, format string, args ...any) {
*errs = append(*errs, FieldError{path, code, fmt.Sprintf(format, args...)})
*vd.errs = append(*vd.errs, FieldError{path, code, fmt.Sprintf(format, args...)})
}
switch s.Type {
case TypeObject:
@@ -100,7 +121,7 @@ func validateValue(s *Schema, v any, path string, errs *FieldErrors) {
add(CodeType, "must be an object")
return
}
validateObject(s, obj, path, errs)
vd.object(s, obj, path)
return
case TypeArray:
items, ok := v.([]any)
@@ -109,7 +130,7 @@ func validateValue(s *Schema, v any, path string, errs *FieldErrors) {
return
}
for i, item := range items {
validateValue(s.Items, item, fmt.Sprintf("%s[%d]", path, i), errs)
vd.value(s.Items, item, fmt.Sprintf("%s[%d]", path, i))
}
return
case TypeString:
+3
View File
@@ -310,6 +310,9 @@ func RegisterIMChannelRoutes(r *gin.RouterGroup, imHandler *handler.IMHandler, g
channels := g.apiKeyGroup(r.Group("/im-channels"), apiKeyManageChannels(apiKeyFullAccess()))
{
channels.GET("", g.Viewer(), imHandler.ListAllIMChannels)
channels.GET("/platforms", g.Viewer(), imHandler.ListIMPlatforms)
// Credentials for the editor, secrets redacted — Admin+ like updates.
channels.GET("/:id", g.Admin(), imHandler.GetIMChannel)
channels.PUT("/:id", g.Admin(), imHandler.UpdateIMChannel)
channels.DELETE("/:id", g.Admin(), imHandler.DeleteIMChannel)
channels.POST("/:id/toggle", g.Admin(), imHandler.ToggleIMChannel)
@@ -118,7 +118,9 @@ flowchart LR
| `POST /api/v1/agents/:id/im-channels` | 为 Agent 创建渠道(校验 platform 合法性、填充默认 mode/output_mode) |
| `GET /api/v1/agents/:id/im-channels` | 列出 Agent 的渠道(不含凭据) |
| `GET /api/v1/im-channels` | 租户内跨 Agent 渠道总览 |
| `PUT /api/v1/im-channels/:id` | 局部更新(name/mode/output_mode/locale/session_mode/knowledge_base_id/credentials/enabled/agent_id);`knowledge_base_id` 传空字符串即解除文件知识库 |
| `GET /api/v1/im-channels/platforms` | 已注册平台目录:接入模式、控制台链接与凭证表单 Schema(前端按它渲染凭证表单) |
| `GET /api/v1/im-channels/:id` | 读取单个渠道供编辑(Admin+),密钥字段返回 `***` |
| `PUT /api/v1/im-channels/:id` | 局部更新(name/mode/output_mode/locale/session_mode/knowledge_base_id/credentials/enabled/agent_id);`knowledge_base_id` 传空字符串即解除文件知识库;`credentials` 按字段合并,密钥传空或 `***` 保留原值 |
| `DELETE /api/v1/im-channels/:id` | 删除 |
| `POST /api/v1/im-channels/:id/toggle` | 启用/停用 |
| `POST /api/v1/wechat/qrcode`、`POST /api/v1/wechat/qrcode/status` | 微信(iLink)扫码绑定:生成二维码并轮询状态 |
+21 -1
View File
@@ -63,9 +63,29 @@ curl $BASE/api/v1/agents/agent-1/im-channels -H "Authorization: Bearer $TOKEN"
curl $BASE/api/v1/im-channels -H "Authorization: Bearer $TOKEN"
```
### GET /api/v1/im-channels/platforms
用途:已注册的 IM 平台目录。每项含 `id`、`name`、`modes`(支持的接入模式,第一个为默认)、`supports_thread`、`links`(控制台与文档链接)和 `config_schema`(凭证表单:JSON Schema 子集,`x-secret` 标记密钥字段,仅适用于某种模式的字段带 `x-visible-if: {"$mode": "webhook"}`)。权限:Viewer+。
响应:200 `{"success":true,"data":[PlatformInfo]}`
```bash
curl $BASE/api/v1/im-channels/platforms -H "Authorization: Bearer $TOKEN"
```
### GET /api/v1/im-channels/:id
用途:读取单个渠道供编辑,含凭证:普通字段为原值,已设置的密钥字段返回 `***`。权限:Admin+。
响应:200 `{"data":{IMChannel}}`
```bash
curl $BASE/api/v1/im-channels/ch-1 -H "Authorization: Bearer $TOKEN"
```
### PUT /api/v1/im-channels/:id
用途:更新渠道(局部更新:`name/mode/output_mode/locale/session_mode/knowledge_base_id/credentials/enabled/agent_id` 均可选;`knowledge_base_id` 传空字符串解除关联,`locale` 传空字符串恢复默认语言)。权限:Admin+。
用途:更新渠道(局部更新:`name/mode/output_mode/locale/session_mode/knowledge_base_id/credentials/enabled/agent_id` 均可选;`knowledge_base_id` 传空字符串解除关联,`locale` 传空字符串恢复默认语言)。`credentials` 按字段合并:未传的字段保留原值,密钥字段传空字符串或 `***` 也保留原值,普通字段传空字符串即清空;传 `{}` 不做任何改动。权限:Admin+。
响应:200 `{"data":{IMChannel}}`
@@ -593,7 +593,7 @@ func registerIMAdapterFactories(imService *imPkg.Service) {
}
```
4. 渠道配置持久化在 `im_channels` 表,会话映射在 `im_channel_sessions`;前端渠道管理页需增加对应平台的配置表单。
4. 在 `internal/im/platforms.go` 的 `platformInfos` 登记平台元数据:支持的接入模式(默认在前)、是否支持话题、控制台链接,以及用 config schema 描述的凭证字段(密钥标 `Secret`,仅某种模式使用的字段用 `VisibleIf: {"$mode": ...}`)。前端渠道管理页按 `/im-channels/platforms` 渲染,无需改表单;引用的前端文案 key 加进 `frontend/src/i18n/backendSchemaKeys.ts`,平台图标放 `frontend/src/assets/img/im/`。渠道配置持久化在 `im_channels` 表,会话映射在 `im_channel_sessions`。
---