Files
ragflow/internal/channels/wecom_webhook.go
Hz_ d69ace8c94 feat(go-channels): add Telegram and WeCom channels (#17564)
## Summary

- Add Telegram long-polling channel support.
- Add WeCom webhook and WebSocket channel support with tests.

Related to #17520
2026-07-31 10:49:54 +08:00

316 lines
9.2 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package channels
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
weComWebhookBodyLimit = 1024 * 1024
weComWebhookShutdownTimeout = 5 * time.Second
weComPKCS7BlockSize = 32
)
type wecomCrypto struct {
token string
aesKey []byte
receiveID string
}
type wecomEncryptedXML struct {
Encrypt string `xml:"Encrypt"`
}
type wecomXMLMessage struct {
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Content string `xml:"Content"`
MsgID string `xml:"MsgId"`
}
type wecomWebhookServerKey struct {
host string
port int
}
type wecomWebhookServer struct {
key wecomWebhookServerKey
server *http.Server
listener net.Listener
mu sync.RWMutex
channels map[string]*wecomChannel
refs int
}
var sharedWeComWebhookServers = struct {
sync.Mutex
servers map[wecomWebhookServerKey]*wecomWebhookServer
}{
servers: map[wecomWebhookServerKey]*wecomWebhookServer{},
}
func newWeComCrypto(token, encodingAESKey, receiveID string) (*wecomCrypto, error) {
if len(encodingAESKey) != 43 {
return nil, fmt.Errorf("WeCom EncodingAESKey must be 43 characters, got %d", len(encodingAESKey))
}
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return nil, fmt.Errorf("decode WeCom EncodingAESKey: %w", err)
}
if len(aesKey) != 32 {
return nil, fmt.Errorf("decoded WeCom AES key must be 32 bytes, got %d", len(aesKey))
}
return &wecomCrypto{
token: token,
aesKey: aesKey,
receiveID: receiveID,
}, nil
}
func (c *wecomCrypto) decrypt(signature, timestamp, nonce, encrypted string) ([]byte, error) {
if signature == "" || timestamp == "" || nonce == "" || encrypted == "" {
return nil, errors.New("missing WeCom signature parameters")
}
if !c.hasValidSignature(signature, timestamp, nonce, encrypted) {
return nil, errors.New("invalid WeCom message signature")
}
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return nil, fmt.Errorf("decode WeCom ciphertext: %w", err)
}
block, err := aes.NewCipher(c.aesKey)
if err != nil {
return nil, err
}
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
return nil, errors.New("invalid WeCom ciphertext length")
}
plaintext := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, c.aesKey[:block.BlockSize()]).CryptBlocks(plaintext, ciphertext)
plaintext, err = removeWeComPKCS7Padding(plaintext)
if err != nil {
return nil, err
}
if len(plaintext) < 20 {
return nil, errors.New("invalid WeCom decrypted payload")
}
messageLength := int(binary.BigEndian.Uint32(plaintext[16:20]))
messageEnd := 20 + messageLength
if messageLength < 0 || messageEnd > len(plaintext) {
return nil, errors.New("invalid WeCom decrypted message length")
}
if receiveID := string(plaintext[messageEnd:]); c.receiveID != "" && receiveID != c.receiveID {
return nil, fmt.Errorf("WeCom receive ID mismatch: got %q", receiveID)
}
return plaintext[20:messageEnd], nil
}
func (c *wecomCrypto) hasValidSignature(signature, timestamp, nonce, encrypted string) bool {
parts := []string{c.token, timestamp, nonce, encrypted}
sort.Strings(parts)
hash := sha1.Sum([]byte(strings.Join(parts, "")))
expected := hex.EncodeToString(hash[:])
return subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) == 1
}
func removeWeComPKCS7Padding(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("empty WeCom padded payload")
}
padding := int(payload[len(payload)-1])
if padding < 1 || padding > weComPKCS7BlockSize || padding > len(payload) {
return nil, errors.New("invalid WeCom PKCS#7 padding")
}
for _, value := range payload[len(payload)-padding:] {
if int(value) != padding {
return nil, errors.New("invalid WeCom PKCS#7 padding bytes")
}
}
return payload[:len(payload)-padding], nil
}
func acquireWeComWebhookServer(channel *wecomChannel) (*wecomWebhookServer, error) {
key := wecomWebhookServerKey{
host: channel.account.WebhookHost,
port: channel.account.WebhookPort,
}
sharedWeComWebhookServers.Lock()
defer sharedWeComWebhookServers.Unlock()
if server := sharedWeComWebhookServers.servers[key]; server != nil {
server.mu.Lock()
server.channels[channel.account.AccountID] = channel
server.refs++
server.mu.Unlock()
return server, nil
}
listener, err := net.Listen("tcp", net.JoinHostPort(key.host, strconv.Itoa(key.port)))
if err != nil {
return nil, fmt.Errorf("start WeCom webhook listener: %w", err)
}
server := &wecomWebhookServer{
key: key,
listener: listener,
channels: map[string]*wecomChannel{
channel.account.AccountID: channel,
},
refs: 1,
}
mux := http.NewServeMux()
mux.HandleFunc("/wecom/", server.handleRequest)
server.server = &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
sharedWeComWebhookServers.servers[key] = server
go func() {
if err := server.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("[wecom] webhook server %s stopped: %v", listener.Addr(), err)
}
}()
log.Printf("[wecom] webhook listening on http://%s/wecom/<account_id>/callback", listener.Addr())
return server, nil
}
func releaseWeComWebhookServer(ctx context.Context, server *wecomWebhookServer, accountID string) error {
sharedWeComWebhookServers.Lock()
server.mu.Lock()
delete(server.channels, accountID)
if server.refs > 0 {
server.refs--
}
remaining := server.refs
server.mu.Unlock()
if remaining > 0 {
sharedWeComWebhookServers.Unlock()
return nil
}
if sharedWeComWebhookServers.servers[server.key] == server {
delete(sharedWeComWebhookServers.servers, server.key)
}
sharedWeComWebhookServers.Unlock()
shutdownCtx, cancel := context.WithTimeout(ctx, weComWebhookShutdownTimeout)
defer cancel()
return server.server.Shutdown(shutdownCtx)
}
func (s *wecomWebhookServer) handleRequest(w http.ResponseWriter, r *http.Request) {
accountID, ok := weComAccountIDFromPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
s.mu.RLock()
channel := s.channels[accountID]
s.mu.RUnlock()
if channel == nil {
http.Error(w, "unknown account", http.StatusNotFound)
return
}
query := r.URL.Query()
signature := query.Get("msg_signature")
timestamp := query.Get("timestamp")
nonce := query.Get("nonce")
if r.Method == http.MethodGet {
plaintext, err := channel.crypto.decrypt(signature, timestamp, nonce, query.Get("echostr"))
if err != nil {
http.Error(w, "bad signature", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write(plaintext)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, weComWebhookBodyLimit))
if err != nil {
log.Printf("[wecom:%s] failed to read request body: %v", accountID, err)
w.WriteHeader(http.StatusOK)
return
}
var envelope wecomEncryptedXML
if err := xml.Unmarshal(body, &envelope); err != nil || strings.TrimSpace(envelope.Encrypt) == "" {
log.Printf("[wecom:%s] invalid encrypted message: %v", accountID, err)
w.WriteHeader(http.StatusOK)
return
}
plaintext, err := channel.crypto.decrypt(signature, timestamp, nonce, envelope.Encrypt)
if err != nil {
http.Error(w, "bad signature", http.StatusForbidden)
return
}
var message wecomXMLMessage
if err := xml.Unmarshal(plaintext, &message); err != nil {
log.Printf("[wecom:%s] failed to parse decrypted message: %v", accountID, err)
w.WriteHeader(http.StatusOK)
return
}
if message.MsgType == "text" && message.FromUserName != "" {
channel.handleTextMessage(r.Context(), message.FromUserName, message.FromUserName, message.MsgID, message.Content, "p2p", map[string]any{
"to_user_name": message.ToUserName,
"from_user_name": message.FromUserName,
"create_time": message.CreateTime,
"msg_type": message.MsgType,
"content": message.Content,
"msg_id": message.MsgID,
"xml": string(plaintext),
})
}
w.WriteHeader(http.StatusOK)
}
func weComAccountIDFromPath(path string) (string, bool) {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) != 3 || parts[0] != "wecom" || parts[2] != "callback" {
return "", false
}
accountID, err := url.PathUnescape(parts[1])
return accountID, err == nil && accountID != ""
}