mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-24 01:37:50 +08:00
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:
@@ -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,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' }])
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)扫码绑定:生成二维码并轮询状态 |
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user