mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-24 16:29:01 +08:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3376,6 +3376,8 @@ export default {
|
||||
"켜면 봇 답변이 채널의 새 최상위 게시물로 올라갑니다. 끄면(기본) 스레드 답변이며 메인 화면에는 「N개 답변」만 보입니다.",
|
||||
modeHint: "WebSocket 방식이 설정이 더 간편하여 권장됩니다",
|
||||
consoleTip: "자격 증명 정보를 가져오세요",
|
||||
wecomWSEndpointHint: "선택 사항. 프라이빗 WeCom 배포 시 사용자 정의 WebSocket 주소를 입력하세요. 비워두면 기본 퍼블릭 클라우드 주소를 사용합니다. 내부 네트워크 주소의 경우 SSRF_WHITELIST 환경 변수에 호스트명을 추가하세요.",
|
||||
wecomAPIBaseURLHint: "선택 사항. 프라이빗 WeCom 배포 시 사용자 정의 API 기본 URL을 입력하세요. 비워두면 기본 퍼블릭 클라우드 주소를 사용합니다. 내부 네트워크 주소의 경우 SSRF_WHITELIST 환경 변수에 호스트명을 추가하세요.",
|
||||
fileKnowledgeBase: "파일 저장 지식 베이스",
|
||||
fileKnowledgeBasePlaceholder: "지식 베이스 선택 (선택 사항)",
|
||||
fileKnowledgeBaseHint: "설정 시 사용자가 보낸 파일이 자동으로 해당 지식 베이스에 저장됩니다",
|
||||
|
||||
@@ -3014,6 +3014,8 @@ export default {
|
||||
'Включено — ответы бота как новые сообщения в канале. Выключено (по умолчанию) — ответы в ветке, в основном окне только «N ответов».',
|
||||
modeHint: 'Рекомендуется WebSocket — проще настроить',
|
||||
consoleTip: 'для получения учётных данных',
|
||||
wecomWSEndpointHint: 'Необязательно. Для приватного развёртывания WeCom укажите пользовательский адрес WebSocket. Оставьте пустым для публичного облака. Для адресов внутренней сети добавьте имя хоста в переменную SSRF_WHITELIST.',
|
||||
wecomAPIBaseURLHint: 'Необязательно. Для приватного развёртывания WeCom укажите пользовательский базовый URL API. Оставьте пустым для публичного облака. Для адресов внутренней сети добавьте имя хоста в переменную SSRF_WHITELIST.',
|
||||
fileKnowledgeBase: 'База знаний для файлов',
|
||||
fileKnowledgeBasePlaceholder: 'Выберите базу знаний (необязательно)',
|
||||
fileKnowledgeBaseHint: 'При настройке файлы, отправленные пользователями, автоматически сохраняются в эту базу знаний',
|
||||
|
||||
@@ -3324,6 +3324,8 @@ export default {
|
||||
"开启后 Bot 回复作为频道内新帖子;关闭(默认)则作为原消息的线程回复,主窗口仅显示「N 条回复」。",
|
||||
modeHint: "推荐使用 WebSocket 方式接入,配置更简单",
|
||||
consoleTip: "前往获取凭证信息",
|
||||
wecomWSEndpointHint: "可选,私有化部署时填写自定义 WebSocket 地址,留空则使用默认公有云地址。内网地址需将域名加入 SSRF_WHITELIST 环境变量",
|
||||
wecomAPIBaseURLHint: "可选,私有化部署时填写自定义 API 地址,留空则使用默认公有云地址。内网地址需将域名加入 SSRF_WHITELIST 环境变量",
|
||||
fileKnowledgeBase: "文件保存知识库",
|
||||
fileKnowledgeBasePlaceholder: "选择知识库(可选)",
|
||||
fileKnowledgeBaseHint: "配置后,用户发送的文件将自动保存到该知识库中",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user