mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 05:23:47 +08:00
feat[Go]: add dingtalk chat bot for chat channel (#17565)
### Summary As title, related to #17520 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
1
go.mod
1
go.mod
@@ -42,6 +42,7 @@ require (
|
||||
github.com/nats-io/nats-server/v2 v2.14.3
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/nikolalohinski/gonja v1.5.3
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.0.5
|
||||
github.com/peterh/liner v1.2.2
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -394,6 +394,8 @@ github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJ
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.0.5 h1:CUAhJXmX6soVTbC6hEjR8J9BqfItW/osZpQ7LAdKtCs=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.0.5/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
|
||||
|
||||
@@ -279,6 +279,8 @@ func buildChannel(accountID string, wanted desiredChannel) (core.Channel, error)
|
||||
switch wanted.channel {
|
||||
case "whatsapp":
|
||||
return newWhatsAppChannelFromConfig(accountID, wanted.credential)
|
||||
case "dingtalk":
|
||||
return newDingTalkChannelFromConfig(accountID, wanted.credential)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown channel: %s", wanted.channel)
|
||||
}
|
||||
|
||||
@@ -15,3 +15,542 @@
|
||||
//
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/clientV2"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
|
||||
|
||||
"ragflow/internal/channels/core"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDingTalkAPIBaseURL = "https://api.dingtalk.com"
|
||||
defaultDingTalkTimeout = 30 * time.Second
|
||||
dingTalkMessageTTL = time.Hour
|
||||
dingTalkQueueSize = 64
|
||||
dingTalkWorkerIdle = 5 * time.Minute
|
||||
)
|
||||
|
||||
type dingTalkAccount struct {
|
||||
AccountID string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
APIBaseURL string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type dingTalkChannel struct {
|
||||
account dingTalkAccount
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
handler core.MessageHandler
|
||||
client *http.Client
|
||||
stream clientV2.OpenDingTalkClient
|
||||
sessionWebhooks map[string]string
|
||||
processed map[string]time.Time
|
||||
inflight map[string]time.Time
|
||||
workers map[string]*dingTalkWorker
|
||||
}
|
||||
|
||||
type dingTalkWorker struct {
|
||||
queue chan dingTalkQueuedMessage
|
||||
}
|
||||
|
||||
type dingTalkQueuedMessage struct {
|
||||
incoming core.IncomingMessage
|
||||
dedupKey string
|
||||
}
|
||||
|
||||
// newDingTalkChannel creates a DingTalk stream channel backed by the official SDK.
|
||||
func newDingTalkChannel(account dingTalkAccount) *dingTalkChannel {
|
||||
if account.APIBaseURL == "" {
|
||||
account.APIBaseURL = defaultDingTalkAPIBaseURL
|
||||
}
|
||||
if account.Timeout <= 0 {
|
||||
account.Timeout = defaultDingTalkTimeout
|
||||
}
|
||||
ch := &dingTalkChannel{
|
||||
account: account,
|
||||
client: &http.Client{Timeout: account.Timeout},
|
||||
sessionWebhooks: map[string]string{},
|
||||
processed: map[string]time.Time{},
|
||||
inflight: map[string]time.Time{},
|
||||
workers: map[string]*dingTalkWorker{},
|
||||
}
|
||||
ch.stream = clientV2.NewBuilder().
|
||||
Credential(&clientV2.AuthClientCredential{ClientId: account.ClientID, ClientSecret: account.ClientSecret}).
|
||||
RegisterCallbackHandler(payload.BotMessageCallbackTopic, ch.handleBotCallback).
|
||||
Build()
|
||||
return ch
|
||||
}
|
||||
|
||||
// newDingTalkChannelFromConfig builds a DingTalk channel from chat_channel.config.credential.
|
||||
func newDingTalkChannelFromConfig(accountID string, cfg map[string]any) (*dingTalkChannel, error) {
|
||||
clientID := firstString(cfg, "client_id", "clientId")
|
||||
clientSecret := firstString(cfg, "client_secret", "clientSecret")
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil, fmt.Errorf("dingtalk account %q is missing client_id or client_secret", accountID)
|
||||
}
|
||||
|
||||
timeout := defaultDingTalkTimeout
|
||||
if raw, ok := cfg["timeout_secs"]; ok {
|
||||
if parsed := dingTalkDurationSeconds(raw); parsed > 0 {
|
||||
timeout = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return newDingTalkChannel(dingTalkAccount{
|
||||
AccountID: accountID,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
APIBaseURL: firstString(cfg, "api_base_url", "api_url"),
|
||||
Timeout: timeout,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ChannelID returns the platform identifier used by the chat-channel runtime.
|
||||
func (c *dingTalkChannel) ChannelID() string {
|
||||
return "dingtalk"
|
||||
}
|
||||
|
||||
// AccountID returns the chat_channel.id bound to this DingTalk runtime instance.
|
||||
func (c *dingTalkChannel) AccountID() string {
|
||||
return c.account.AccountID
|
||||
}
|
||||
|
||||
// SetMessageHandler installs the RAGFlow bridge invoked for inbound DingTalk messages.
|
||||
func (c *dingTalkChannel) SetMessageHandler(handler core.MessageHandler) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.handler = handler
|
||||
}
|
||||
|
||||
// Start begins the DingTalk official stream client.
|
||||
func (c *dingTalkChannel) Start(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
if c.cancel != nil {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
c.ctx = runCtx
|
||||
c.cancel = cancel
|
||||
c.mu.Unlock()
|
||||
|
||||
go c.run(runCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels the DingTalk stream client and clears transient runtime state.
|
||||
func (c *dingTalkChannel) Stop(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
cancel := c.cancel
|
||||
c.cancel = nil
|
||||
c.ctx = nil
|
||||
c.sessionWebhooks = map[string]string{}
|
||||
c.processed = map[string]time.Time{}
|
||||
c.inflight = map[string]time.Time{}
|
||||
c.workers = map[string]*dingTalkWorker{}
|
||||
c.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send posts an outgoing RAGFlow answer to DingTalk through the cached sessionWebhook.
|
||||
func (c *dingTalkChannel) Send(ctx context.Context, msg core.OutgoingMessage) error {
|
||||
if strings.TrimSpace(msg.ChatID) == "" {
|
||||
return errors.New("chat_id is required")
|
||||
}
|
||||
if strings.TrimSpace(msg.Text) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
sessionWebhook := c.sessionWebhooks[msg.ChatID]
|
||||
c.mu.Unlock()
|
||||
if strings.TrimSpace(sessionWebhook) == "" {
|
||||
log.Printf("[dingtalk:%s] no sessionWebhook cached for chat_id=%s; dropping reply", c.account.AccountID, msg.ChatID)
|
||||
return nil
|
||||
}
|
||||
|
||||
p := map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{
|
||||
"title": "RAGFlow",
|
||||
"text": msg.Text,
|
||||
},
|
||||
}
|
||||
return c.postWebhook(ctx, sessionWebhook, p)
|
||||
}
|
||||
|
||||
// run keeps the DingTalk SDK stream client active until the channel stops.
|
||||
func (c *dingTalkChannel) run(ctx context.Context) {
|
||||
defer func() {
|
||||
var runCancel context.CancelFunc
|
||||
c.mu.Lock()
|
||||
if c.ctx == ctx {
|
||||
runCancel = c.cancel
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if runCancel != nil {
|
||||
runCancel()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.ctx == ctx {
|
||||
c.cancel = nil
|
||||
c.ctx = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
if c.stream == nil {
|
||||
log.Printf("[dingtalk:%s] stream client is not initialized", c.account.AccountID)
|
||||
return
|
||||
}
|
||||
if err := c.stream.Start(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Printf("[dingtalk:%s] stream client exited: %v", c.account.AccountID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBotCallback receives DingTalk bot messages from the official stream SDK.
|
||||
func (c *dingTalkChannel) handleBotCallback(data *chatbot.BotCallbackDataModel) (*chatbot.BotCallbackRespModel, error) {
|
||||
c.mu.Lock()
|
||||
ctx := c.ctx
|
||||
c.mu.Unlock()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
incoming, dedupKey, ok := c.normalizeBotMessage(data)
|
||||
if !ok {
|
||||
return &chatbot.BotCallbackRespModel{}, nil
|
||||
}
|
||||
if dedupKey != "" && !c.beginMessage(dedupKey) {
|
||||
log.Printf("[dingtalk:%s] skipping duplicate message=%s", c.account.AccountID, dedupKey)
|
||||
return &chatbot.BotCallbackRespModel{}, nil
|
||||
}
|
||||
if !c.enqueueIncoming(ctx, dingTalkQueuedMessage{incoming: incoming, dedupKey: dedupKey}) && dedupKey != "" {
|
||||
c.cancelInflight(dedupKey)
|
||||
}
|
||||
return &chatbot.BotCallbackRespModel{}, nil
|
||||
}
|
||||
|
||||
// normalizeBotMessage converts DingTalk SDK bot callback data to the chat-channel message model.
|
||||
func (c *dingTalkChannel) normalizeBotMessage(data *chatbot.BotCallbackDataModel) (core.IncomingMessage, string, bool) {
|
||||
if data == nil {
|
||||
return core.IncomingMessage{}, "", false
|
||||
}
|
||||
chatID := dingTalkFirstNonEmpty(data.ConversationId)
|
||||
senderID := dingTalkFirstNonEmpty(data.SenderId, data.SenderStaffId)
|
||||
text := strings.TrimSpace(data.Text.Content)
|
||||
if text == "" {
|
||||
text = dingTalkExtractText(data.Content)
|
||||
}
|
||||
if data.SessionWebhook != "" && chatID != "" {
|
||||
c.saveSessionWebhook(chatID, data.SessionWebhook)
|
||||
}
|
||||
if text == "" || chatID == "" || senderID == "" {
|
||||
return core.IncomingMessage{}, "", false
|
||||
}
|
||||
|
||||
raw := map[string]any{}
|
||||
if encoded, err := json.Marshal(data); err == nil {
|
||||
_ = json.Unmarshal(encoded, &raw)
|
||||
}
|
||||
dedupKey := dingTalkBotDedupKey(data, text)
|
||||
return core.IncomingMessage{
|
||||
Channel: c.ChannelID(),
|
||||
AccountID: c.account.AccountID,
|
||||
ChatID: chatID,
|
||||
ChatType: data.ConversationType,
|
||||
MessageID: dingTalkFirstNonEmpty(data.MsgId, dedupKey),
|
||||
SenderID: senderID,
|
||||
Text: text,
|
||||
Raw: raw,
|
||||
}, dedupKey, true
|
||||
}
|
||||
|
||||
// enqueueIncoming schedules DingTalk message handling and marks duplicates as processed after handling.
|
||||
func (c *dingTalkChannel) enqueueIncoming(ctx context.Context, item dingTalkQueuedMessage) bool {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var worker *dingTalkWorker
|
||||
startWorker := false
|
||||
queueFull := false
|
||||
|
||||
c.mu.Lock()
|
||||
if c.workers == nil {
|
||||
c.workers = map[string]*dingTalkWorker{}
|
||||
}
|
||||
worker = c.workers[item.incoming.ChatID]
|
||||
if worker == nil {
|
||||
worker = &dingTalkWorker{queue: make(chan dingTalkQueuedMessage, dingTalkQueueSize)}
|
||||
c.workers[item.incoming.ChatID] = worker
|
||||
startWorker = true
|
||||
}
|
||||
select {
|
||||
case worker.queue <- item:
|
||||
c.mu.Unlock()
|
||||
if startWorker {
|
||||
go c.runWorker(ctx, item.incoming.ChatID, worker)
|
||||
}
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
c.mu.Unlock()
|
||||
default:
|
||||
queueFull = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if startWorker {
|
||||
go c.runWorker(ctx, item.incoming.ChatID, worker)
|
||||
}
|
||||
if queueFull {
|
||||
log.Printf("[dingtalk:%s] dropping message %s for chat %s: queue is full", c.account.AccountID, item.incoming.MessageID, item.incoming.ChatID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runWorker processes one DingTalk chat's inbound messages sequentially.
|
||||
func (c *dingTalkChannel) runWorker(ctx context.Context, chatID string, worker *dingTalkWorker) {
|
||||
idle := time.NewTimer(dingTalkWorkerIdle)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case item := <-worker.queue:
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.handleIncoming(ctx, item.incoming)
|
||||
if item.dedupKey != "" {
|
||||
c.finishMessage(item.dedupKey)
|
||||
}
|
||||
resetTimer(idle, dingTalkWorkerIdle)
|
||||
case <-idle.C:
|
||||
if c.retireWorker(chatID, worker) {
|
||||
return
|
||||
}
|
||||
resetTimer(idle, dingTalkWorkerIdle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retireWorker removes an idle DingTalk chat worker only while it is current and empty.
|
||||
func (c *dingTalkChannel) retireWorker(chatID string, worker *dingTalkWorker) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
current := c.workers[chatID]
|
||||
if current != worker {
|
||||
return true
|
||||
}
|
||||
if len(worker.queue) > 0 {
|
||||
return false
|
||||
}
|
||||
delete(c.workers, chatID)
|
||||
return true
|
||||
}
|
||||
|
||||
// handleIncoming invokes the installed RAGFlow bridge for one queued DingTalk message.
|
||||
func (c *dingTalkChannel) handleIncoming(ctx context.Context, incoming core.IncomingMessage) {
|
||||
c.mu.Lock()
|
||||
handler := c.handler
|
||||
c.mu.Unlock()
|
||||
if handler == nil {
|
||||
return
|
||||
}
|
||||
if err := handler(ctx, incoming); err != nil {
|
||||
log.Printf("[dingtalk:%s] message handler error: %v", c.account.AccountID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// beginMessage marks a DingTalk message as in-flight if it is not a processed duplicate.
|
||||
func (c *dingTalkChannel) beginMessage(dedupKey string) bool {
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.pruneMessageCacheLocked(now)
|
||||
if _, ok := c.processed[dedupKey]; ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := c.inflight[dedupKey]; ok {
|
||||
return false
|
||||
}
|
||||
c.inflight[dedupKey] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// finishMessage moves a DingTalk message from in-flight to processed after handling.
|
||||
func (c *dingTalkChannel) finishMessage(dedupKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.inflight, dedupKey)
|
||||
c.processed[dedupKey] = time.Now()
|
||||
}
|
||||
|
||||
// cancelInflight removes an in-flight duplicate key when enqueue fails.
|
||||
func (c *dingTalkChannel) cancelInflight(dedupKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.inflight, dedupKey)
|
||||
}
|
||||
|
||||
// pruneMessageCacheLocked removes expired duplicate-tracking entries while c.mu is held.
|
||||
func (c *dingTalkChannel) pruneMessageCacheLocked(now time.Time) {
|
||||
cutoff := now.Add(-dingTalkMessageTTL)
|
||||
for key, ts := range c.processed {
|
||||
if ts.Before(cutoff) {
|
||||
delete(c.processed, key)
|
||||
}
|
||||
}
|
||||
for key, ts := range c.inflight {
|
||||
if ts.Before(cutoff) {
|
||||
delete(c.inflight, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// saveSessionWebhook caches the DingTalk reply webhook for one conversation.
|
||||
func (c *dingTalkChannel) saveSessionWebhook(chatID, sessionWebhook string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.sessionWebhooks[chatID] = sessionWebhook
|
||||
}
|
||||
|
||||
// postWebhook posts a markdown reply payload to a DingTalk sessionWebhook.
|
||||
func (c *dingTalkChannel) postWebhook(ctx context.Context, sessionWebhook string, payload map[string]any) error {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sessionWebhook, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("dingtalk reply status: %d, response: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dingTalkExtractText extracts user text from DingTalk message payload variants.
|
||||
func dingTalkExtractText(data map[string]any) string {
|
||||
text := data["text"]
|
||||
if textMap := dingTalkMap(text); len(textMap) > 0 {
|
||||
if content := dingTalkString(textMap["content"]); content != "" {
|
||||
return content
|
||||
}
|
||||
}
|
||||
if content := dingTalkString(text); content != "" {
|
||||
return content
|
||||
}
|
||||
|
||||
content := data["content"]
|
||||
if contentMap := dingTalkMap(content); len(contentMap) > 0 {
|
||||
for _, key := range []string{"text", "content", "recognition"} {
|
||||
if value := dingTalkString(contentMap[key]); strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return dingTalkString(content)
|
||||
}
|
||||
|
||||
// dingTalkBotDedupKey builds a stable duplicate key for a DingTalk bot callback payload.
|
||||
func dingTalkBotDedupKey(data *chatbot.BotCallbackDataModel, text string) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
if data.MsgId != "" {
|
||||
return "msg:" + data.MsgId
|
||||
}
|
||||
if data.ConversationId != "" && dingTalkFirstNonEmpty(data.SenderId, data.SenderStaffId) != "" && strings.TrimSpace(text) != "" {
|
||||
sum := sha1.Sum([]byte(text))
|
||||
eventTS := ""
|
||||
if data.CreateAt > 0 {
|
||||
eventTS = ":" + strconv.FormatInt(data.CreateAt, 10)
|
||||
}
|
||||
return fmt.Sprintf("fallback:%s:%s%s:%s", data.ConversationId, dingTalkFirstNonEmpty(data.SenderId, data.SenderStaffId), eventTS, hex.EncodeToString(sum[:])[:16])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dingTalkDurationSeconds converts a config value measured in seconds to a duration.
|
||||
func dingTalkDurationSeconds(value any) time.Duration {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return time.Duration(v) * time.Second
|
||||
case int64:
|
||||
return time.Duration(v) * time.Second
|
||||
case float64:
|
||||
return time.Duration(v) * time.Second
|
||||
case json.Number:
|
||||
n, err := v.Int64()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// dingTalkString converts common JSON values to strings.
|
||||
func dingTalkString(value any) string {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// dingTalkMap returns value as a string-keyed map when possible.
|
||||
func dingTalkMap(value any) map[string]any {
|
||||
if out, ok := value.(map[string]any); ok {
|
||||
return out
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// dingTalkFirstNonEmpty returns the first non-empty string.
|
||||
func dingTalkFirstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
165
internal/channels/dingtalk_test.go
Normal file
165
internal/channels/dingtalk_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
|
||||
"ragflow/internal/channels/core"
|
||||
)
|
||||
|
||||
func TestNewDingTalkChannelFromConfigRequiresCredentials(t *testing.T) {
|
||||
if _, err := newDingTalkChannelFromConfig("account-1", map[string]any{"client_id": "client-1"}); err == nil {
|
||||
t.Fatal("newDingTalkChannelFromConfig succeeded without client_secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDingTalkChannelFromConfigNormalizesConfig(t *testing.T) {
|
||||
ch, err := newDingTalkChannelFromConfig("account-1", map[string]any{
|
||||
"clientId": "client-1",
|
||||
"clientSecret": "secret-1",
|
||||
"api_base_url": "https://dingtalk.example",
|
||||
"timeout_secs": "7",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newDingTalkChannelFromConfig returned error: %v", err)
|
||||
}
|
||||
if ch.account.ClientID != "client-1" {
|
||||
t.Fatalf("client id = %q, want client-1", ch.account.ClientID)
|
||||
}
|
||||
if ch.account.ClientSecret != "secret-1" {
|
||||
t.Fatalf("client secret = %q, want secret-1", ch.account.ClientSecret)
|
||||
}
|
||||
if ch.account.APIBaseURL != "https://dingtalk.example" {
|
||||
t.Fatalf("api base url = %q, want https://dingtalk.example", ch.account.APIBaseURL)
|
||||
}
|
||||
if ch.account.Timeout != 7*time.Second {
|
||||
t.Fatalf("timeout = %s, want 7s", ch.account.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkExtractText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
want string
|
||||
}{
|
||||
{name: "text object", data: map[string]any{"text": map[string]any{"content": "hello"}}, want: "hello"},
|
||||
{name: "text string", data: map[string]any{"text": "hello"}, want: "hello"},
|
||||
{name: "content object text", data: map[string]any{"content": map[string]any{"text": "hello"}}, want: "hello"},
|
||||
{name: "content object recognition", data: map[string]any{"content": map[string]any{"recognition": "hello"}}, want: "hello"},
|
||||
{name: "content string", data: map[string]any{"content": "hello"}, want: "hello"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := dingTalkExtractText(tt.data); got != tt.want {
|
||||
t.Fatalf("dingTalkExtractText() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkBotDedupKey(t *testing.T) {
|
||||
if got := dingTalkBotDedupKey(&chatbot.BotCallbackDataModel{MsgId: "msg-1"}, "hello"); got != "msg:msg-1" {
|
||||
t.Fatalf("dedup key = %q, want msg:msg-1", got)
|
||||
}
|
||||
|
||||
got := dingTalkBotDedupKey(&chatbot.BotCallbackDataModel{
|
||||
ConversationId: "conv-1",
|
||||
SenderId: "sender-1",
|
||||
CreateAt: 1000,
|
||||
}, "hello")
|
||||
if got != "fallback:conv-1:sender-1:1000:aaf4c61ddcc5e8a2" {
|
||||
t.Fatalf("fallback dedup key = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkNormalizeBotMessage(t *testing.T) {
|
||||
ch := newDingTalkChannel(dingTalkAccount{AccountID: "account-1", ClientID: "client-1", ClientSecret: "secret-1"})
|
||||
|
||||
incoming, dedupKey, ok := ch.normalizeBotMessage(&chatbot.BotCallbackDataModel{
|
||||
ConversationId: "chat-1",
|
||||
ConversationType: "2",
|
||||
MsgId: "msg-1",
|
||||
SenderId: "sender-1",
|
||||
SessionWebhook: "https://webhook.example",
|
||||
Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("normalizeBotMessage failed")
|
||||
}
|
||||
if incoming.Channel != "dingtalk" || incoming.AccountID != "account-1" || incoming.ChatID != "chat-1" || incoming.MessageID != "msg-1" || incoming.SenderID != "sender-1" || incoming.Text != "hello" {
|
||||
t.Fatalf("unexpected incoming message: %+v", incoming)
|
||||
}
|
||||
if dedupKey != "msg:msg-1" {
|
||||
t.Fatalf("dedup key = %q, want msg:msg-1", dedupKey)
|
||||
}
|
||||
if ch.sessionWebhooks["chat-1"] != "https://webhook.example" {
|
||||
t.Fatal("sessionWebhook was not cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkSendUsesCachedSessionWebhook(t *testing.T) {
|
||||
requestBody := map[string]any{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Errorf("decode body: %v", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch := newDingTalkChannel(dingTalkAccount{AccountID: "account-1", ClientID: "client-1", ClientSecret: "secret-1"})
|
||||
ch.sessionWebhooks["chat-1"] = server.URL
|
||||
|
||||
err := ch.Send(context.Background(), core.OutgoingMessage{ChatID: "chat-1", Text: "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Send returned error: %v", err)
|
||||
}
|
||||
if requestBody["msgtype"] != "markdown" {
|
||||
t.Fatalf("msgtype = %v, want markdown", requestBody["msgtype"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkEnqueueFailureCancelsInflight(t *testing.T) {
|
||||
ch := newDingTalkChannel(dingTalkAccount{AccountID: "account-1", ClientID: "client-1", ClientSecret: "secret-1"})
|
||||
worker := &dingTalkWorker{queue: make(chan dingTalkQueuedMessage, 1)}
|
||||
worker.queue <- dingTalkQueuedMessage{incoming: core.IncomingMessage{MessageID: "queued"}}
|
||||
ch.workers["chat-1"] = worker
|
||||
ch.inflight["msg:drop"] = time.Now()
|
||||
|
||||
ok := ch.enqueueIncoming(context.Background(), dingTalkQueuedMessage{
|
||||
incoming: core.IncomingMessage{ChatID: "chat-1", MessageID: "drop"},
|
||||
dedupKey: "msg:drop",
|
||||
})
|
||||
if ok {
|
||||
t.Fatal("enqueueIncoming succeeded for a full queue")
|
||||
}
|
||||
ch.cancelInflight("msg:drop")
|
||||
if _, exists := ch.inflight["msg:drop"]; exists {
|
||||
t.Fatal("inflight key remains after enqueue failure")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user