feat(im): support custom endpoints for private WeCom deployments

Allow configuring custom WebSocket and API base URLs for WeCom
via optional credentials fields (ws_endpoint, api_base_url), enabling
integration with privately deployed WeCom instances.

Changes:
- LongConnClient and WebhookAdapter accept custom endpoint URLs
- Endpoint URLs validated at construction (scheme + SSRF check)
- Private deployment hostnames added to file download SSRF allowlist
- Frontend forms added for both WebSocket and Webhook modes
- i18n support for zh-CN, en-US, ru-RU, ko-KR

Uses default public cloud endpoints when not specified.
This commit is contained in:
nullkey
2026-04-13 22:54:42 +08:00
committed by lyingbug
parent d4edd374df
commit 30f349d06a
10 changed files with 144 additions and 53 deletions
@@ -174,6 +174,11 @@
<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-hint">{{ $t('agentEditor.im.wecomWSEndpointHint') }}</p>
</div>
</template>
<template v-else>
<div class="form-item">
@@ -196,6 +201,11 @@
<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-hint">{{ $t('agentEditor.im.wecomAPIBaseURLHint') }}</p>
</div>
</template>
</template>
+2
View File
@@ -3349,6 +3349,8 @@ export default {
'When on, bot replies are new top-level posts in the channel. When off (default), they stay in the thread and the main view only shows “N replies”.',
modeHint: 'WebSocket is recommended for easier setup',
consoleTip: 'to get credentials',
wecomWSEndpointHint: 'Optional. For private WeCom deployments, enter a custom WebSocket address. Leave empty to use the default public cloud endpoint. For internal network addresses, add the hostname to the SSRF_WHITELIST env var.',
wecomAPIBaseURLHint: 'Optional. For private WeCom deployments, enter a custom API base URL. Leave empty to use the default public cloud endpoint. For internal network addresses, add the hostname to the SSRF_WHITELIST env var.',
fileKnowledgeBase: 'File Storage Knowledge Base',
fileKnowledgeBasePlaceholder: 'Select a knowledge base (optional)',
fileKnowledgeBaseHint: 'When configured, files sent by users will be automatically saved to this knowledge base',
+2
View File
@@ -3376,6 +3376,8 @@ export default {
"켜면 봇 답변이 채널의 새 최상위 게시물로 올라갑니다. 끄면(기본) 스레드 답변이며 메인 화면에는 「N개 답변」만 보입니다.",
modeHint: "WebSocket 방식이 설정이 더 간편하여 권장됩니다",
consoleTip: "자격 증명 정보를 가져오세요",
wecomWSEndpointHint: "선택 사항. 프라이빗 WeCom 배포 시 사용자 정의 WebSocket 주소를 입력하세요. 비워두면 기본 퍼블릭 클라우드 주소를 사용합니다. 내부 네트워크 주소의 경우 SSRF_WHITELIST 환경 변수에 호스트명을 추가하세요.",
wecomAPIBaseURLHint: "선택 사항. 프라이빗 WeCom 배포 시 사용자 정의 API 기본 URL을 입력하세요. 비워두면 기본 퍼블릭 클라우드 주소를 사용합니다. 내부 네트워크 주소의 경우 SSRF_WHITELIST 환경 변수에 호스트명을 추가하세요.",
fileKnowledgeBase: "파일 저장 지식 베이스",
fileKnowledgeBasePlaceholder: "지식 베이스 선택 (선택 사항)",
fileKnowledgeBaseHint: "설정 시 사용자가 보낸 파일이 자동으로 해당 지식 베이스에 저장됩니다",
+2
View File
@@ -3014,6 +3014,8 @@ export default {
'Включено — ответы бота как новые сообщения в канале. Выключено (по умолчанию) — ответы в ветке, в основном окне только «N ответов».',
modeHint: 'Рекомендуется WebSocket — проще настроить',
consoleTip: 'для получения учётных данных',
wecomWSEndpointHint: 'Необязательно. Для приватного развёртывания WeCom укажите пользовательский адрес WebSocket. Оставьте пустым для публичного облака. Для адресов внутренней сети добавьте имя хоста в переменную SSRF_WHITELIST.',
wecomAPIBaseURLHint: 'Необязательно. Для приватного развёртывания WeCom укажите пользовательский базовый URL API. Оставьте пустым для публичного облака. Для адресов внутренней сети добавьте имя хоста в переменную SSRF_WHITELIST.',
fileKnowledgeBase: 'База знаний для файлов',
fileKnowledgeBasePlaceholder: 'Выберите базу знаний (необязательно)',
fileKnowledgeBaseHint: 'При настройке файлы, отправленные пользователями, автоматически сохраняются в эту базу знаний',
+2
View File
@@ -3324,6 +3324,8 @@ export default {
"开启后 Bot 回复作为频道内新帖子;关闭(默认)则作为原消息的线程回复,主窗口仅显示「N 条回复」。",
modeHint: "推荐使用 WebSocket 方式接入,配置更简单",
consoleTip: "前往获取凭证信息",
wecomWSEndpointHint: "可选,私有化部署时填写自定义 WebSocket 地址,留空则使用默认公有云地址。内网地址需将域名加入 SSRF_WHITELIST 环境变量",
wecomAPIBaseURLHint: "可选,私有化部署时填写自定义 API 地址,留空则使用默认公有云地址。内网地址需将域名加入 SSRF_WHITELIST 环境变量",
fileKnowledgeBase: "文件保存知识库",
fileKnowledgeBasePlaceholder: "选择知识库(可选)",
fileKnowledgeBaseHint: "配置后,用户发送的文件将自动保存到该知识库中",
+6 -1
View File
@@ -1059,6 +1059,7 @@ func registerIMAdapterFactories(imService *imPkg.Service) {
getString(creds, "token"),
getString(creds, "encoding_aes_key"),
corpAgentID,
getString(creds, "api_base_url"),
)
if err != nil {
return nil, nil, err
@@ -1066,12 +1067,16 @@ func registerIMAdapterFactories(imService *imPkg.Service) {
return adapter, nil, nil
case "websocket":
client := wecom.NewLongConnClient(
client, err := wecom.NewLongConnClient(
getString(creds, "bot_id"),
getString(creds, "bot_secret"),
getString(creds, "ws_endpoint"),
getString(creds, "bot_name"),
msgHandler,
)
if err != nil {
return nil, nil, err
}
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
+22 -10
View File
@@ -27,7 +27,7 @@ import (
)
const (
wecomWSEndpoint = "wss://openws.work.weixin.qq.com"
defaultWSEndpoint = "wss://openws.work.weixin.qq.com"
cmdSubscribe = "aibot_subscribe"
cmdPing = "ping"
@@ -122,9 +122,11 @@ type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
// LongConnClient manages a WeCom intelligent bot WebSocket long connection.
type LongConnClient struct {
botID string
secret string
handler MessageHandler
botID string
secret string
endpoint string
extraAllowedHost string // hostname from custom endpoint for SSRF allowlist
handler MessageHandler
conn *ws.Conn
mu sync.Mutex
@@ -143,17 +145,27 @@ type LongConnClient struct {
}
// NewLongConnClient creates a WeCom long connection client.
// wsEndpoint overrides the default WebSocket URL; empty uses the public cloud endpoint.
// botName is the bot's display name for @mention stripping; empty to auto-detect.
func NewLongConnClient(botID, secret, botName string, handler MessageHandler) *LongConnClient {
func NewLongConnClient(botID, secret, wsEndpoint, botName string, handler MessageHandler) (*LongConnClient, error) {
if wsEndpoint == "" {
wsEndpoint = defaultWSEndpoint
}
wsEndpoint = strings.TrimRight(wsEndpoint, "/")
if err := validateEndpointURL(wsEndpoint, defaultWSEndpoint, "wss"); err != nil {
return nil, fmt.Errorf("invalid ws_endpoint: %w", err)
}
c := &LongConnClient{
botID: botID,
secret: secret,
handler: handler,
botID: botID,
secret: secret,
endpoint: wsEndpoint,
extraAllowedHost: extraHostFromEndpoint(wsEndpoint, defaultWSEndpoint),
handler: handler,
}
if botName != "" {
c.botDisplayName.Store(botName)
}
return c
return c, nil
}
// Start connects and runs the long connection loop. It reconnects automatically on failure.
@@ -345,7 +357,7 @@ func (c *LongConnClient) sendStreamFrame(incoming *im.IncomingMessage, streamID,
}
func (c *LongConnClient) connectAndRun(ctx context.Context) error {
conn, _, err := ws.DefaultDialer.DialContext(ctx, wecomWSEndpoint, nil)
conn, _, err := ws.DefaultDialer.DialContext(ctx, c.endpoint, nil)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
+4 -1
View File
@@ -85,7 +85,10 @@ func TestLongConnClient_StripAtMention(t *testing.T) {
})
t.Run("NewLongConnClient with bot name", func(t *testing.T) {
c := NewLongConnClient("id", "secret", "My Bot", nil)
c, err := NewLongConnClient("id", "secret", "", "My Bot", nil)
if err != nil {
t.Fatal(err)
}
got := c.stripAtMention("@My Bot /help")
if got != "/help" {
+93 -40
View File
@@ -39,15 +39,52 @@ import (
var httpClient = &http.Client{Timeout: 30 * time.Second}
const defaultAPIBaseURL = "https://qyapi.weixin.qq.com"
// extraHostFromEndpoint returns the lowercased hostname from endpoint if it
// differs from defaultEndpoint; otherwise returns "". Used to extend the SSRF
// allowlist for private deployments.
func extraHostFromEndpoint(endpoint, defaultEndpoint string) string {
if endpoint == "" || endpoint == defaultEndpoint {
return ""
}
if u, err := url.Parse(endpoint); err == nil {
return strings.ToLower(u.Hostname())
}
return ""
}
// validateEndpointURL checks that a custom endpoint URL uses a secure scheme
// and does not point to a private/internal address, preventing accidental
// credential leakage (e.g. access tokens sent to a rogue server).
func validateEndpointURL(endpoint, defaultEndpoint, requiredScheme string) error {
if endpoint == "" || endpoint == defaultEndpoint {
return nil
}
u, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid endpoint URL: %w", err)
}
if u.Scheme != requiredScheme {
return fmt.Errorf("endpoint must use %s:// scheme, got %s://", requiredScheme, u.Scheme)
}
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return fmt.Errorf("%w (for private deployments on internal networks, add the hostname to SSRF_WHITELIST)", err)
}
return nil
}
// WebhookAdapter implements im.Adapter for WeCom in webhook (self-built app callback) mode.
// Messages arrive via HTTP callback; replies are sent via the WeCom REST API.
type WebhookAdapter struct {
corpID string
token string
encodingAESKey string
aesKey []byte
agentSecret string
corpAgentID int
corpID string
token string
encodingAESKey string
aesKey []byte
agentSecret string
corpAgentID int
apiBaseURL string // WeCom API base URL (e.g. "https://qyapi.weixin.qq.com")
extraAllowedHost string // hostname from apiBaseURL for SSRF allowlist (empty if default)
// Token cache
tokenMu sync.Mutex
@@ -59,20 +96,32 @@ type WebhookAdapter struct {
var _ im.FileDownloader = (*WebhookAdapter)(nil)
// NewWebhookAdapter creates a new WeCom webhook adapter.
func NewWebhookAdapter(corpID, agentSecret, token, encodingAESKey string, corpAgentID int) (*WebhookAdapter, error) {
// apiBaseURL overrides the default WeCom API base URL; empty uses the public cloud endpoint.
func NewWebhookAdapter(corpID, agentSecret, token, encodingAESKey string, corpAgentID int, apiBaseURL string) (*WebhookAdapter, error) {
// Decode the AES key from base64
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return nil, fmt.Errorf("decode encoding_aes_key: %w", err)
}
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
apiBaseURL = strings.TrimRight(apiBaseURL, "/")
if err := validateEndpointURL(apiBaseURL, defaultAPIBaseURL, "https"); err != nil {
return nil, fmt.Errorf("invalid api_base_url: %w", err)
}
return &WebhookAdapter{
corpID: corpID,
token: token,
encodingAESKey: encodingAESKey,
aesKey: aesKey,
agentSecret: agentSecret,
corpAgentID: corpAgentID,
corpID: corpID,
token: token,
encodingAESKey: encodingAESKey,
aesKey: aesKey,
agentSecret: agentSecret,
corpAgentID: corpAgentID,
apiBaseURL: apiBaseURL,
extraAllowedHost: extraHostFromEndpoint(apiBaseURL, defaultAPIBaseURL),
}, nil
}
@@ -257,7 +306,7 @@ func (a *WebhookAdapter) sendToAppChat(ctx context.Context, accessToken, chatID
return fmt.Errorf("marshal payload: %w", err)
}
sendURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=%s", accessToken)
sendURL := fmt.Sprintf("%s/cgi-bin/appchat/send?access_token=%s", a.apiBaseURL, accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("create request: %w", err)
@@ -301,7 +350,7 @@ func (a *WebhookAdapter) sendToUser(ctx context.Context, accessToken, userID str
return fmt.Errorf("marshal payload: %w", err)
}
sendURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s", accessToken)
sendURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", a.apiBaseURL, accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("create request: %w", err)
@@ -338,8 +387,8 @@ func (a *WebhookAdapter) getAccessToken(ctx context.Context) (string, error) {
return a.tokenCache, nil
}
tokenURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
a.corpID, a.agentSecret)
tokenURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
a.apiBaseURL, a.corpID, a.agentSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
@@ -460,10 +509,10 @@ type wecomMessage struct {
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Content string `xml:"Content"` // text
PicUrl string `xml:"PicUrl"` // image: download URL
MediaId string `xml:"MediaId"` // image/voice/video: media ID for download
Format string `xml:"Format"` // voice: audio format (amr/speex)
ThumbMediaId string `xml:"ThumbMediaId"` // video: thumbnail media ID
PicUrl string `xml:"PicUrl"` // image: download URL
MediaId string `xml:"MediaId"` // image/voice/video: media ID for download
Format string `xml:"Format"` // voice: audio format (amr/speex)
ThumbMediaId string `xml:"ThumbMediaId"` // video: thumbnail media ID
MsgID string `xml:"MsgId"`
AgentID string `xml:"AgentID"`
ChatID string `xml:"ChatId"`
@@ -488,7 +537,7 @@ func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessa
// If FileKey looks like a URL, download directly
if strings.HasPrefix(msg.FileKey, "http://") || strings.HasPrefix(msg.FileKey, "https://") {
return downloadFromURL(ctx, msg.FileKey, fileName)
return downloadFromURL(ctx, msg.FileKey, fileName, a.extraAllowedHost)
}
// Otherwise treat as media_id, download via temporary media API
@@ -497,9 +546,9 @@ func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessa
return nil, "", fmt.Errorf("get access token: %w", err)
}
apiURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=%s&media_id=%s",
accessToken, msg.FileKey)
return downloadFromURL(ctx, apiURL, fileName)
apiURL := fmt.Sprintf("%s/cgi-bin/media/get?access_token=%s&media_id=%s",
a.apiBaseURL, accessToken, msg.FileKey)
return downloadFromURL(ctx, apiURL, fileName, a.extraAllowedHost)
}
// downloadFromURL performs a GET request and returns the response body.
@@ -507,9 +556,9 @@ func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessa
// 1. Content-Disposition: attachment; filename="xxx.pdf"
// 2. Content-Type → extension mapping (fallback for platforms like WeCom that
// don't provide the original filename in the callback JSON)
func downloadFromURL(ctx context.Context, rawURL, fileName string) (io.ReadCloser, string, error) {
func downloadFromURL(ctx context.Context, rawURL, fileName string, extraAllowedHost string) (io.ReadCloser, string, error) {
// SSRF protection: reject internal/private URLs unless on the WeCom API allowlist.
if !isAllowedIMAPIHost(rawURL) {
if !isAllowedIMAPIHost(rawURL, extraAllowedHost) {
if err := secutils.ValidateURLForSSRF(rawURL); err != nil {
return nil, "", fmt.Errorf("URL rejected for security reasons: %v", err)
}
@@ -600,12 +649,16 @@ var allowedIMAPIHosts = []string{
}
// isAllowedIMAPIHost returns true if rawURL points to a known IM platform API host.
func isAllowedIMAPIHost(rawURL string) bool {
// extraHost is an optional additional trusted hostname (e.g. from a private deployment).
func isAllowedIMAPIHost(rawURL string, extraHost string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
hostname := strings.ToLower(u.Hostname())
if extraHost != "" && hostname == extraHost {
return true
}
for _, allowed := range allowedIMAPIHosts {
if hostname == allowed {
return true
@@ -623,20 +676,20 @@ func contentTypeToExt(ct string) string {
ct = strings.ToLower(ct)
mapping := map[string]string{
"application/pdf": "pdf",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/pdf": "pdf",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"text/plain": "txt",
"text/markdown": "md",
"text/csv": "csv",
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"text/plain": "txt",
"text/markdown": "md",
"text/csv": "csv",
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
}
return mapping[ct]
+1 -1
View File
@@ -82,7 +82,7 @@ func (a *WSAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (
}
// Download the (encrypted) file content
reader, fileName, err := downloadFromURL(ctx, msg.FileKey, fileName)
reader, fileName, err := downloadFromURL(ctx, msg.FileKey, fileName, a.client.extraAllowedHost)
if err != nil {
return nil, "", err
}