diff --git a/go.mod b/go.mod index 7e788c96ba..d399ffe451 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 69b65466dc..e82da0b254 100644 --- a/go.sum +++ b/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= diff --git a/internal/channels/bootstrap.go b/internal/channels/bootstrap.go index 3da5899bd7..44431a6c54 100644 --- a/internal/channels/bootstrap.go +++ b/internal/channels/bootstrap.go @@ -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) } diff --git a/internal/channels/dingtalk.go b/internal/channels/dingtalk.go index a4ce67278c..a49e89078d 100644 --- a/internal/channels/dingtalk.go +++ b/internal/channels/dingtalk.go @@ -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 == "" { + 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 "" +} diff --git a/internal/channels/dingtalk_test.go b/internal/channels/dingtalk_test.go new file mode 100644 index 0000000000..3906b9bf55 --- /dev/null +++ b/internal/channels/dingtalk_test.go @@ -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") + } +}