WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,968 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// httpClient is a shared HTTP client with a reasonable timeout for DingTalk API calls.
|
||||
var httpClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
|
||||
Timeout: 15 * time.Second,
|
||||
MaxRedirects: 5,
|
||||
})
|
||||
|
||||
// apiBaseURL is the DingTalk OpenAPI host. Overridable in tests.
|
||||
var apiBaseURL = "https://api.dingtalk.com"
|
||||
|
||||
// minCardUpdateInterval is the minimum time between consecutive card streaming updates.
|
||||
const minCardUpdateInterval = 500 * time.Millisecond
|
||||
|
||||
// dingtalkConvTypeGroup is the DingTalk conversation type value for group chats.
|
||||
const dingtalkConvTypeGroup = "2"
|
||||
|
||||
// Compile-time checks.
|
||||
var (
|
||||
_ im.Adapter = (*Adapter)(nil)
|
||||
_ im.StreamSender = (*Adapter)(nil)
|
||||
_ im.FileDownloader = (*Adapter)(nil)
|
||||
)
|
||||
|
||||
// Adapter implements im.Adapter for DingTalk.
|
||||
type Adapter struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
cardTemplateID string // optional: enables AI card streaming when set
|
||||
|
||||
// accessToken cache
|
||||
tokenMu sync.RWMutex
|
||||
token string
|
||||
tokenExpAt time.Time
|
||||
}
|
||||
|
||||
// NewWebhookAdapter creates a DingTalk adapter for HTTP callback mode.
|
||||
func NewWebhookAdapter(clientID, clientSecret, cardTemplateID string) *Adapter {
|
||||
startStreamReaper()
|
||||
return &Adapter{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
cardTemplateID: cardTemplateID,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdapter creates a DingTalk adapter for stream (websocket) mode.
|
||||
// The stream connection itself is managed separately by the supervisor; the
|
||||
// adapter only sends replies (via sessionWebhook or OpenAPI).
|
||||
func NewAdapter(clientID, clientSecret, cardTemplateID string) *Adapter {
|
||||
startStreamReaper()
|
||||
return &Adapter{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
cardTemplateID: cardTemplateID,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) Platform() im.Platform {
|
||||
return im.PlatformDingtalk
|
||||
}
|
||||
|
||||
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyCallback verifies the DingTalk webhook signature (HmacSHA256).
|
||||
func (a *Adapter) VerifyCallback(c *gin.Context) error {
|
||||
if a.clientSecret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
timestamp := c.GetHeader("Timestamp")
|
||||
sign := c.GetHeader("Sign")
|
||||
if timestamp == "" || sign == "" {
|
||||
return fmt.Errorf("missing timestamp or sign header")
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
diff := time.Now().UnixMilli() - ts
|
||||
if diff > 3600*1000 || diff < -3600*1000 {
|
||||
return fmt.Errorf("timestamp expired")
|
||||
}
|
||||
|
||||
stringToSign := timestamp + "\n" + a.clientSecret
|
||||
h := hmac.New(sha256.New, []byte(a.clientSecret))
|
||||
h.Write([]byte(stringToSign))
|
||||
expectedSign := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
if !hmac.Equal([]byte(sign), []byte(expectedSign)) {
|
||||
return fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DingTalk callback message structure.
|
||||
type callbackMessage struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
ConversationType string `json:"conversationType"`
|
||||
MsgID string `json:"msgId"`
|
||||
Msgtype string `json:"msgtype"`
|
||||
Text *textContent `json:"text"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
SenderNick string `json:"senderNick"`
|
||||
SenderStaffId string `json:"senderStaffId"`
|
||||
SenderID string `json:"senderId"`
|
||||
SessionWebhook string `json:"sessionWebhook"`
|
||||
RobotCode string `json:"robotCode"`
|
||||
AtUsers []atUser `json:"atUsers"`
|
||||
IsInAtList bool `json:"isInAtList"`
|
||||
ChatbotCorpId string `json:"chatbotCorpId"`
|
||||
}
|
||||
|
||||
type textContent struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type atUser struct {
|
||||
DingtalkID string `json:"dingtalkId"`
|
||||
StaffID string `json:"staffId"`
|
||||
}
|
||||
|
||||
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
var msg callbackMessage
|
||||
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
|
||||
return nil, fmt.Errorf("parse callback: %w", err)
|
||||
}
|
||||
|
||||
return parseCallbackMessage(&msg), nil
|
||||
}
|
||||
|
||||
// fileMessageContent is the `content` object DingTalk sends for file and
|
||||
// picture messages. File messages carry fileName/spaceId/fileId; picture
|
||||
// messages carry only downloadCode (original quality) and pictureDownloadCode.
|
||||
// See https://open-dingtalk.github.io/developerpedia/docs/learn/bot/message/
|
||||
type fileMessageContent struct {
|
||||
DownloadCode string `json:"downloadCode"`
|
||||
PictureDownloadCode string `json:"pictureDownloadCode"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
// richTextMessageContent is the payload DingTalk sends for msgtype=richText.
|
||||
// Text and pictures are interleaved in their original display order. The
|
||||
// unified IM message currently carries one downloadable file, so we retain the
|
||||
// first picture while preserving all text fragments for the QA query.
|
||||
type richTextMessageContent struct {
|
||||
RichText []richTextElement `json:"richText"`
|
||||
}
|
||||
|
||||
type richTextElement struct {
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
DownloadCode string `json:"downloadCode"`
|
||||
PictureDownloadCode string `json:"pictureDownloadCode"`
|
||||
}
|
||||
|
||||
type parsedRichText struct {
|
||||
Text string
|
||||
DownloadCode string
|
||||
PictureCount int
|
||||
}
|
||||
|
||||
func parseRichTextContent(msgtype string, content json.RawMessage) (parsedRichText, bool) {
|
||||
if !strings.EqualFold(strings.TrimSpace(msgtype), "richText") {
|
||||
return parsedRichText{}, false
|
||||
}
|
||||
|
||||
var payload richTextMessageContent
|
||||
if len(content) == 0 || json.Unmarshal(content, &payload) != nil {
|
||||
return parsedRichText{}, false
|
||||
}
|
||||
|
||||
textParts := make([]string, 0, len(payload.RichText))
|
||||
result := parsedRichText{}
|
||||
for _, item := range payload.RichText {
|
||||
if text := strings.TrimSpace(item.Text); text != "" {
|
||||
textParts = append(textParts, text)
|
||||
}
|
||||
|
||||
code := pictureDownloadCode(item)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
result.PictureCount++
|
||||
if result.DownloadCode == "" {
|
||||
result.DownloadCode = code
|
||||
}
|
||||
}
|
||||
result.Text = strings.Join(textParts, "\n")
|
||||
return result, true
|
||||
}
|
||||
|
||||
func pictureDownloadCode(item richTextElement) string {
|
||||
if !strings.EqualFold(strings.TrimSpace(item.Type), "picture") {
|
||||
return ""
|
||||
}
|
||||
if item.DownloadCode != "" {
|
||||
return item.DownloadCode
|
||||
}
|
||||
return item.PictureDownloadCode
|
||||
}
|
||||
|
||||
type audioMessageContent struct {
|
||||
Recognition string `json:"recognition"`
|
||||
}
|
||||
|
||||
func parseAudioContent(msgtype string, content json.RawMessage) (string, bool) {
|
||||
if !strings.EqualFold(strings.TrimSpace(msgtype), "audio") {
|
||||
return "", false
|
||||
}
|
||||
var payload audioMessageContent
|
||||
if len(content) == 0 || json.Unmarshal(content, &payload) != nil {
|
||||
return "", false
|
||||
}
|
||||
text := strings.TrimSpace(payload.Recognition)
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
|
||||
// parseFileContent maps a DingTalk msgtype + content object to WeKnora's file
|
||||
// message fields. Returns ok=false for non-file/picture message types so the
|
||||
// caller keeps its text handling. Picture messages have no fileName; the IM
|
||||
// service appends an extension after download.
|
||||
func parseFileContent(msgtype string, content json.RawMessage) (im.MessageType, string, string, bool) {
|
||||
var msgType im.MessageType
|
||||
switch msgtype {
|
||||
case "file":
|
||||
msgType = im.MessageTypeFile
|
||||
case "picture":
|
||||
msgType = im.MessageTypeImage
|
||||
default:
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
var c fileMessageContent
|
||||
if len(content) > 0 {
|
||||
if err := json.Unmarshal(content, &c); err != nil {
|
||||
return "", "", "", false
|
||||
}
|
||||
}
|
||||
downloadCode := c.DownloadCode
|
||||
if downloadCode == "" {
|
||||
downloadCode = c.PictureDownloadCode
|
||||
}
|
||||
if downloadCode == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
fileName := c.FileName
|
||||
if msgType == im.MessageTypeImage {
|
||||
fileName = ""
|
||||
}
|
||||
return msgType, fileName, downloadCode, true
|
||||
}
|
||||
|
||||
// parseDownloadURL extracts the temporary downloadUrl from the response of the
|
||||
// robot/messageFiles/download API.
|
||||
func parseDownloadURL(raw json.RawMessage) (string, error) {
|
||||
var r struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return "", fmt.Errorf("parse download response: %w", err)
|
||||
}
|
||||
if r.DownloadURL == "" {
|
||||
return "", fmt.Errorf("download response has no downloadUrl: %s", string(raw))
|
||||
}
|
||||
return r.DownloadURL, nil
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file/picture the user sent to the robot. DingTalk
|
||||
// does not deliver the bytes directly: the callback carries a downloadCode that
|
||||
// is exchanged for a temporary downloadUrl via robot/messageFiles/download,
|
||||
// which is then fetched. robotCode comes from the callback (webhook mode) or the
|
||||
// app client ID (stream mode). Implements im.FileDownloader (issue #1771).
|
||||
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
|
||||
downloadCode := msg.FileKey
|
||||
if downloadCode == "" {
|
||||
return nil, "", fmt.Errorf("no downloadCode in message")
|
||||
}
|
||||
robotCode := msg.Extra["robot_code"]
|
||||
if robotCode == "" {
|
||||
robotCode = a.clientID
|
||||
}
|
||||
|
||||
respBody, err := a.dingtalkAPI(ctx, http.MethodPost, "/v1.0/robot/messageFiles/download", map[string]string{
|
||||
"robotCode": robotCode,
|
||||
"downloadCode": downloadCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request download url: %w", err)
|
||||
}
|
||||
downloadURL, err := parseDownloadURL(respBody)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateFileDownloadURL(downloadURL); err != nil {
|
||||
return nil, "", fmt.Errorf("download url rejected: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download file: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("download file returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return resp.Body, msg.FileName, nil
|
||||
}
|
||||
|
||||
func parseCallbackMessage(msg *callbackMessage) *im.IncomingMessage {
|
||||
chatType := im.ChatTypeDirect
|
||||
chatID := ""
|
||||
if msg.ConversationType == dingtalkConvTypeGroup {
|
||||
chatType = im.ChatTypeGroup
|
||||
chatID = msg.ConversationID
|
||||
}
|
||||
|
||||
userID := msg.SenderStaffId
|
||||
if userID == "" {
|
||||
userID = msg.SenderID
|
||||
}
|
||||
|
||||
extra := map[string]string{
|
||||
"session_webhook": msg.SessionWebhook,
|
||||
"raw_msgtype": msg.Msgtype,
|
||||
}
|
||||
incoming := &im.IncomingMessage{
|
||||
Platform: im.PlatformDingtalk,
|
||||
UserID: userID,
|
||||
UserName: msg.SenderNick,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MessageID: msg.MsgID,
|
||||
Extra: extra,
|
||||
}
|
||||
|
||||
if rich, ok := parseRichTextContent(msg.Msgtype, msg.Content); ok {
|
||||
applyRichText(incoming, extra, rich, msg.MsgID, msg.RobotCode)
|
||||
} else if msgType, fileName, downloadCode, ok := parseFileContent(msg.Msgtype, msg.Content); ok {
|
||||
incoming.MessageType = msgType
|
||||
incoming.FileName = defaultFileName(msgType, fileName, msg.MsgID)
|
||||
incoming.FileKey = downloadCode
|
||||
extra["robot_code"] = msg.RobotCode
|
||||
} else if text, ok := parseAudioContent(msg.Msgtype, msg.Content); ok {
|
||||
incoming.MessageType = im.MessageTypeText
|
||||
incoming.Content = text
|
||||
} else {
|
||||
incoming.MessageType = im.MessageTypeText
|
||||
if msg.Text != nil {
|
||||
incoming.Content = strings.TrimSpace(msg.Text.Content)
|
||||
}
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
func applyRichText(
|
||||
incoming *im.IncomingMessage,
|
||||
extra map[string]string,
|
||||
rich parsedRichText,
|
||||
msgID, robotCode string,
|
||||
) {
|
||||
incoming.Content = rich.Text
|
||||
if rich.DownloadCode != "" {
|
||||
incoming.MessageType = im.MessageTypeImage
|
||||
} else {
|
||||
incoming.MessageType = im.MessageTypeText
|
||||
}
|
||||
|
||||
if rich.PictureCount == 0 {
|
||||
return
|
||||
}
|
||||
extra["rich_text_picture_count"] = strconv.Itoa(rich.PictureCount)
|
||||
if rich.DownloadCode == "" {
|
||||
return
|
||||
}
|
||||
incoming.FileKey = rich.DownloadCode
|
||||
incoming.FileName = defaultFileName(im.MessageTypeImage, "", msgID)
|
||||
extra["robot_code"] = robotCode
|
||||
if rich.PictureCount > 1 {
|
||||
incoming.Content = appendDroppedPictureHint(incoming.Content, rich.PictureCount)
|
||||
}
|
||||
}
|
||||
|
||||
func appendDroppedPictureHint(text string, pictureCount int) string {
|
||||
hint := fmt.Sprintf("(该消息共 %d 张图片,当前仅处理第一张)", pictureCount)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return hint
|
||||
}
|
||||
return text + "\n" + hint
|
||||
}
|
||||
|
||||
// defaultFileName gives picture messages (which carry no fileName) a name with a
|
||||
// real stem derived from the message ID, mirroring the WeCom adapter. File
|
||||
// messages keep their original name; if missing, fall back to the message ID so
|
||||
// post-download extension resolution can still run.
|
||||
func defaultFileName(msgType im.MessageType, fileName, msgID string) string {
|
||||
if fileName != "" {
|
||||
return fileName
|
||||
}
|
||||
if msgType == im.MessageTypeImage {
|
||||
return msgID + ".png"
|
||||
}
|
||||
return msgID
|
||||
}
|
||||
|
||||
// allowedDingTalkDownloadHostSuffixes lists CDN/OSS host suffixes DingTalk uses
|
||||
// for temporary file download links returned by messageFiles/download.
|
||||
var allowedDingTalkDownloadHostSuffixes = []string{
|
||||
".aliyuncs.com",
|
||||
".dingtalk.com",
|
||||
}
|
||||
|
||||
// validateFileDownloadURL is overridable in tests (httptest uses loopback URLs).
|
||||
var validateFileDownloadURL = defaultValidateFileDownloadURL
|
||||
|
||||
func defaultValidateFileDownloadURL(rawURL string) error {
|
||||
if isAllowedDingTalkDownloadHost(rawURL) {
|
||||
return nil
|
||||
}
|
||||
return secutils.ValidateURLForSSRF(rawURL)
|
||||
}
|
||||
|
||||
func isAllowedDingTalkDownloadHost(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hostname := strings.ToLower(u.Hostname())
|
||||
for _, suffix := range allowedDingTalkDownloadHostSuffixes {
|
||||
if strings.HasSuffix(hostname, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// streamToIncoming builds an IncomingMessage from a DingTalk Stream mode
|
||||
// callback. The Stream SDK does not expose robotCode, so file messages fall back
|
||||
// to the app client ID (which is the robotCode for enterprise internal robots).
|
||||
func streamToIncoming(data *chatbot.BotCallbackDataModel, fallbackRobotCode string) *im.IncomingMessage {
|
||||
chatType := im.ChatTypeDirect
|
||||
chatID := ""
|
||||
if data.ConversationType == dingtalkConvTypeGroup {
|
||||
chatType = im.ChatTypeGroup
|
||||
chatID = data.ConversationId
|
||||
}
|
||||
|
||||
userID := data.SenderStaffId
|
||||
if userID == "" {
|
||||
userID = data.SenderId
|
||||
}
|
||||
|
||||
extra := map[string]string{
|
||||
"session_webhook": data.SessionWebhook,
|
||||
"raw_msgtype": data.Msgtype,
|
||||
}
|
||||
incoming := &im.IncomingMessage{
|
||||
Platform: im.PlatformDingtalk,
|
||||
UserID: userID,
|
||||
UserName: data.SenderNick,
|
||||
ChatID: chatID,
|
||||
ChatType: chatType,
|
||||
MessageID: data.MsgId,
|
||||
Extra: extra,
|
||||
}
|
||||
|
||||
// data.Content is a decoded interface{}; re-marshal it to JSON so the same
|
||||
// parseFileContent helper used by the webhook path can read it.
|
||||
var contentRaw json.RawMessage
|
||||
if data.Content != nil {
|
||||
if b, err := json.Marshal(data.Content); err == nil {
|
||||
contentRaw = b
|
||||
}
|
||||
}
|
||||
|
||||
if rich, ok := parseRichTextContent(data.Msgtype, contentRaw); ok {
|
||||
applyRichText(incoming, extra, rich, data.MsgId, fallbackRobotCode)
|
||||
} else if msgType, fileName, downloadCode, ok := parseFileContent(data.Msgtype, contentRaw); ok {
|
||||
incoming.MessageType = msgType
|
||||
incoming.FileName = defaultFileName(msgType, fileName, data.MsgId)
|
||||
incoming.FileKey = downloadCode
|
||||
extra["robot_code"] = fallbackRobotCode
|
||||
} else if text, ok := parseAudioContent(data.Msgtype, contentRaw); ok {
|
||||
incoming.MessageType = im.MessageTypeText
|
||||
incoming.Content = text
|
||||
} else {
|
||||
incoming.MessageType = im.MessageTypeText
|
||||
incoming.Content = strings.TrimSpace(data.Text.Content)
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
// ── Send reply ──
|
||||
|
||||
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
content := im.FormatIMDisplayContent(reply.Content, im.StreamDisplayFinal)
|
||||
|
||||
sessionWebhook := ""
|
||||
if incoming.Extra != nil {
|
||||
sessionWebhook = incoming.Extra["session_webhook"]
|
||||
}
|
||||
|
||||
if sessionWebhook != "" {
|
||||
return a.replyViaSessionWebhook(ctx, sessionWebhook, content)
|
||||
}
|
||||
return a.replyViaOpenAPI(ctx, incoming, content)
|
||||
}
|
||||
|
||||
func (a *Adapter) replyViaSessionWebhook(ctx context.Context, webhookURL, content string) error {
|
||||
if err := secutils.ValidateURLForSSRF(webhookURL); err != nil {
|
||||
return fmt.Errorf("dingtalk sessionWebhook rejected by SSRF policy: %w", err)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{
|
||||
"title": "Reply",
|
||||
"text": content,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal reply: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send reply: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("dingtalk sessionWebhook returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) replyViaOpenAPI(ctx context.Context, incoming *im.IncomingMessage, content string) error {
|
||||
token, err := a.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
msgParam, err := json.Marshal(map[string]string{"title": "Reply", "text": content})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal msgParam: %w", err)
|
||||
}
|
||||
|
||||
var apiURL string
|
||||
body := map[string]interface{}{
|
||||
"robotCode": a.clientID,
|
||||
"msgKey": "sampleMarkdown",
|
||||
"msgParam": string(msgParam),
|
||||
}
|
||||
|
||||
if incoming.ChatType == im.ChatTypeGroup {
|
||||
apiURL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
||||
body["openConversationId"] = incoming.ChatID
|
||||
} else {
|
||||
apiURL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
|
||||
body["userIds"] = []string{incoming.UserID}
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-acs-dingtalk-access-token", token)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send reply: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("dingtalk OpenAPI returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccessToken returns a cached or fresh DingTalk access token.
|
||||
func (a *Adapter) getAccessToken(ctx context.Context) (string, error) {
|
||||
a.tokenMu.RLock()
|
||||
if a.token != "" && time.Now().Before(a.tokenExpAt) {
|
||||
token := a.token
|
||||
a.tokenMu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
a.tokenMu.RUnlock()
|
||||
|
||||
a.tokenMu.Lock()
|
||||
defer a.tokenMu.Unlock()
|
||||
|
||||
if a.token != "" && time.Now().Before(a.tokenExpAt) {
|
||||
return a.token, nil
|
||||
}
|
||||
|
||||
body := map[string]string{
|
||||
"appKey": a.clientID,
|
||||
"appSecret": a.clientSecret,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
apiBaseURL+"/v1.0/oauth2/accessToken",
|
||||
bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("dingtalk accessToken returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return "", fmt.Errorf("empty access token from dingtalk")
|
||||
}
|
||||
|
||||
a.token = result.AccessToken
|
||||
a.tokenExpAt = time.Now().Add(time.Duration(result.ExpireIn)*time.Second - 5*time.Minute)
|
||||
|
||||
return a.token, nil
|
||||
}
|
||||
|
||||
// ── DingTalk OpenAPI helpers for AI Card ──
|
||||
|
||||
func (a *Adapter) dingtalkAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
token, err := a.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
|
||||
url := apiBaseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-acs-dingtalk-access-token", token)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("dingtalk API %s returned %d: %s", path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// createAndDeliverCard creates an AI card and delivers it to the conversation.
|
||||
func (a *Adapter) createAndDeliverCard(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
|
||||
outTrackID := uuid.New().String()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"cardTemplateId": a.cardTemplateID,
|
||||
"outTrackId": outTrackID,
|
||||
"callbackType": "STREAM",
|
||||
"cardData": map[string]interface{}{
|
||||
"cardParamMap": map[string]string{
|
||||
"content": "",
|
||||
},
|
||||
},
|
||||
"userIdType": 1,
|
||||
}
|
||||
|
||||
if incoming.ChatType == im.ChatTypeGroup {
|
||||
// Group chat
|
||||
convID := incoming.ChatID
|
||||
body["openSpaceId"] = "dtv1.card//IM_GROUP." + convID
|
||||
body["imGroupOpenSpaceModel"] = map[string]interface{}{"supportForward": true}
|
||||
body["imGroupOpenDeliverModel"] = map[string]interface{}{
|
||||
"robotCode": a.clientID,
|
||||
"extension": map[string]string{},
|
||||
}
|
||||
} else {
|
||||
// Single chat (1:1 DM)
|
||||
body["openSpaceId"] = "dtv1.card//IM_ROBOT." + incoming.UserID
|
||||
body["imRobotOpenSpaceModel"] = map[string]interface{}{"supportForward": true}
|
||||
body["imRobotOpenDeliverModel"] = map[string]interface{}{
|
||||
"robotCode": a.clientID,
|
||||
"spaceType": "IM_ROBOT",
|
||||
"extension": map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.dingtalkAPI(ctx, http.MethodPost, "/v1.0/card/instances/createAndDeliver", body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create card: %w", err)
|
||||
}
|
||||
|
||||
return outTrackID, nil
|
||||
}
|
||||
|
||||
// streamingUpdateCard pushes content to an existing AI card.
|
||||
func (a *Adapter) streamingUpdateCard(ctx context.Context, outTrackID, content string, isFinalize bool) error {
|
||||
body := map[string]interface{}{
|
||||
"outTrackId": outTrackID,
|
||||
"guid": uuid.New().String(),
|
||||
"key": "content",
|
||||
"content": content,
|
||||
"isFull": true,
|
||||
"isFinalize": isFinalize,
|
||||
"isError": false,
|
||||
}
|
||||
|
||||
_, err := a.dingtalkAPI(ctx, http.MethodPut, "/v1.0/card/streaming", body)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── StreamSender implementation ──
|
||||
|
||||
type streamState struct {
|
||||
mu sync.Mutex
|
||||
content strings.Builder
|
||||
sessionWebhook string
|
||||
outTrackID string // non-empty when using AI card streaming
|
||||
lastUpdate time.Time // for card update throttling
|
||||
createdAt time.Time // for orphan stream detection
|
||||
}
|
||||
|
||||
const (
|
||||
streamOrphanTTL = 5 * time.Minute
|
||||
streamReaperInterval = 1 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
streamsMu sync.Mutex
|
||||
dStreams = map[string]*streamState{}
|
||||
startReaperOnce sync.Once
|
||||
reaperStopCh = make(chan struct{})
|
||||
)
|
||||
|
||||
// startStreamReaper starts a background goroutine (once) that periodically
|
||||
// removes orphaned stream entries. This prevents memory leaks when EndStream
|
||||
// is never called due to panics or pipeline errors.
|
||||
func startStreamReaper() {
|
||||
startReaperOnce.Do(func() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(streamReaperInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
cutoff := time.Now().Add(-streamOrphanTTL)
|
||||
streamsMu.Lock()
|
||||
for id, state := range dStreams {
|
||||
if state.createdAt.Before(cutoff) {
|
||||
delete(dStreams, id)
|
||||
}
|
||||
}
|
||||
streamsMu.Unlock()
|
||||
case <-reaperStopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
|
||||
sessionWebhook := ""
|
||||
if incoming.Extra != nil {
|
||||
sessionWebhook = incoming.Extra["session_webhook"]
|
||||
}
|
||||
|
||||
streamID := fmt.Sprintf("dt:%s:%s", incoming.UserID, incoming.MessageID)
|
||||
|
||||
state := &streamState{
|
||||
sessionWebhook: sessionWebhook,
|
||||
createdAt: time.Now(),
|
||||
}
|
||||
|
||||
// If card template is configured, create an AI card for streaming
|
||||
if a.cardTemplateID != "" {
|
||||
outTrackID, err := a.createAndDeliverCard(ctx, incoming)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[DingTalk] Failed to create AI card, falling back to sessionWebhook: %v", err)
|
||||
} else {
|
||||
state.outTrackID = outTrackID
|
||||
}
|
||||
}
|
||||
|
||||
streamsMu.Lock()
|
||||
dStreams[streamID] = state
|
||||
streamsMu.Unlock()
|
||||
|
||||
logger.Infof(ctx, "[DingTalk] Streaming started: stream_id=%s, card=%v", streamID, state.outTrackID != "")
|
||||
return streamID, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
|
||||
if fullContent == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
streamsMu.Lock()
|
||||
state, ok := dStreams[streamID]
|
||||
streamsMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown stream ID: %s", streamID)
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.content.Reset()
|
||||
state.content.WriteString(fullContent)
|
||||
|
||||
if state.outTrackID == "" {
|
||||
state.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Since(state.lastUpdate) < minCardUpdateInterval {
|
||||
state.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
state.lastUpdate = time.Now()
|
||||
outTrackID := state.outTrackID
|
||||
state.mu.Unlock()
|
||||
|
||||
if err := a.streamingUpdateCard(ctx, outTrackID, fullContent, false); err != nil {
|
||||
logger.Warnf(ctx, "[DingTalk] Failed to update card stream: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
|
||||
streamsMu.Lock()
|
||||
state, ok := dStreams[streamID]
|
||||
streamsMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown stream ID: %s", streamID)
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.content.Reset()
|
||||
state.content.WriteString(finalContent)
|
||||
outTrackID := state.outTrackID
|
||||
state.mu.Unlock()
|
||||
|
||||
if outTrackID != "" {
|
||||
if err := a.streamingUpdateCard(ctx, outTrackID, finalContent, false); err != nil {
|
||||
logger.Warnf(ctx, "[DingTalk] Failed to finalize card stream: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
|
||||
return a.UpdateStreamContent(ctx, incoming, streamID, content)
|
||||
}
|
||||
|
||||
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
|
||||
streamsMu.Lock()
|
||||
state, ok := dStreams[streamID]
|
||||
delete(dStreams, streamID)
|
||||
streamsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
fullContent := state.content.String()
|
||||
outTrackID := state.outTrackID
|
||||
sessionWebhook := state.sessionWebhook
|
||||
state.mu.Unlock()
|
||||
|
||||
if outTrackID != "" {
|
||||
if err := a.streamingUpdateCard(ctx, outTrackID, fullContent, true); err != nil {
|
||||
logger.Warnf(ctx, "[DingTalk] Failed to finalize card stream: %v", err)
|
||||
}
|
||||
} else if sessionWebhook != "" {
|
||||
if err := a.replyViaSessionWebhook(ctx, sessionWebhook, fullContent); err != nil {
|
||||
logger.Warnf(ctx, "[DingTalk] Failed to end stream: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := a.replyViaOpenAPI(ctx, incoming, fullContent); err != nil {
|
||||
logger.Warnf(ctx, "[DingTalk] Failed to end stream via OpenAPI: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[DingTalk] Streaming ended: stream_id=%s", streamID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
)
|
||||
|
||||
// Compile-time check: the adapter must implement FileDownloader so the IM
|
||||
// service dispatches file messages to it (issue #1771).
|
||||
var _ im.FileDownloader = (*Adapter)(nil)
|
||||
|
||||
func TestParseFileContent_File(t *testing.T) {
|
||||
content := json.RawMessage(`{"spaceId":"223573","fileName":"foobar.zip","downloadCode":"LJYYbw==","fileId":"117848"}`)
|
||||
msgType, fileName, downloadCode, ok := parseFileContent("file", content)
|
||||
if !ok {
|
||||
t.Fatalf("expected ok=true for file message")
|
||||
}
|
||||
if msgType != im.MessageTypeFile {
|
||||
t.Errorf("msgType = %q, want %q", msgType, im.MessageTypeFile)
|
||||
}
|
||||
if fileName != "foobar.zip" {
|
||||
t.Errorf("fileName = %q, want %q", fileName, "foobar.zip")
|
||||
}
|
||||
if downloadCode != "LJYYbw==" {
|
||||
t.Errorf("downloadCode = %q, want %q", downloadCode, "LJYYbw==")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileContent_Picture(t *testing.T) {
|
||||
content := json.RawMessage(`{"pictureDownloadCode":"pWjAks=","downloadCode":"mIofJE0E"}`)
|
||||
msgType, fileName, downloadCode, ok := parseFileContent("picture", content)
|
||||
if !ok {
|
||||
t.Fatalf("expected ok=true for picture message")
|
||||
}
|
||||
if msgType != im.MessageTypeImage {
|
||||
t.Errorf("msgType = %q, want %q", msgType, im.MessageTypeImage)
|
||||
}
|
||||
if fileName != "" {
|
||||
t.Errorf("fileName = %q, want empty (pipeline appends extension)", fileName)
|
||||
}
|
||||
if downloadCode != "mIofJE0E" {
|
||||
t.Errorf("downloadCode = %q, want %q", downloadCode, "mIofJE0E")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileContent_Text(t *testing.T) {
|
||||
_, _, _, ok := parseFileContent("text", nil)
|
||||
if ok {
|
||||
t.Errorf("expected ok=false for text message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileContent_EmptyDownloadCode(t *testing.T) {
|
||||
_, _, _, ok := parseFileContent("file", json.RawMessage(`{"fileName":"a.pdf"}`))
|
||||
if ok {
|
||||
t.Errorf("expected ok=false when downloadCode is missing")
|
||||
}
|
||||
_, _, _, ok = parseFileContent("picture", json.RawMessage(`{}`))
|
||||
if ok {
|
||||
t.Errorf("expected ok=false when picture has no download code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileContent_PictureDownloadCodeFallback(t *testing.T) {
|
||||
content := json.RawMessage(`{"pictureDownloadCode":"pWjAks="}`)
|
||||
msgType, fileName, downloadCode, ok := parseFileContent("picture", content)
|
||||
if !ok {
|
||||
t.Fatalf("expected ok=true when only pictureDownloadCode is present")
|
||||
}
|
||||
if msgType != im.MessageTypeImage {
|
||||
t.Errorf("msgType = %q, want %q", msgType, im.MessageTypeImage)
|
||||
}
|
||||
if fileName != "" {
|
||||
t.Errorf("fileName = %q, want empty", fileName)
|
||||
}
|
||||
if downloadCode != "pWjAks=" {
|
||||
t.Errorf("downloadCode = %q, want %q", downloadCode, "pWjAks=")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileContent_InvalidJSON(t *testing.T) {
|
||||
_, _, _, ok := parseFileContent("file", json.RawMessage(`not-json`))
|
||||
if ok {
|
||||
t.Errorf("expected ok=false for malformed content JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFileName(t *testing.T) {
|
||||
if got := defaultFileName(im.MessageTypeImage, "", "pic-1"); got != "pic-1.png" {
|
||||
t.Errorf("image default = %q, want pic-1.png", got)
|
||||
}
|
||||
if got := defaultFileName(im.MessageTypeFile, "spec.pdf", "m1"); got != "spec.pdf" {
|
||||
t.Errorf("file with name = %q, want spec.pdf", got)
|
||||
}
|
||||
if got := defaultFileName(im.MessageTypeFile, "", "m2"); got != "m2" {
|
||||
t.Errorf("file without name = %q, want m2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDownloadURL(t *testing.T) {
|
||||
url, err := parseDownloadURL(json.RawMessage(`{"downloadUrl":"https://example.com/file?token=abc"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "https://example.com/file?token=abc" {
|
||||
t.Errorf("url = %q, want %q", url, "https://example.com/file?token=abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDownloadURL_Missing(t *testing.T) {
|
||||
_, err := parseDownloadURL(json.RawMessage(`{}`))
|
||||
if err == nil {
|
||||
t.Errorf("expected error when downloadUrl is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_File(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "m1",
|
||||
Msgtype: "file",
|
||||
RobotCode: "robot-123",
|
||||
SenderStaffId: "staff-1",
|
||||
SenderNick: "Alice",
|
||||
ConversationType: "1",
|
||||
Content: json.RawMessage(`{"fileName":"spec.pdf","downloadCode":"CODE1","spaceId":"s1"}`),
|
||||
}
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeFile {
|
||||
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeFile)
|
||||
}
|
||||
if got.FileName != "spec.pdf" {
|
||||
t.Errorf("FileName = %q, want %q", got.FileName, "spec.pdf")
|
||||
}
|
||||
if got.FileKey != "CODE1" {
|
||||
t.Errorf("FileKey = %q, want %q", got.FileKey, "CODE1")
|
||||
}
|
||||
if got.Extra["robot_code"] != "robot-123" {
|
||||
t.Errorf("Extra[robot_code] = %q, want %q", got.Extra["robot_code"], "robot-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_Picture(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "pic-1",
|
||||
Msgtype: "picture",
|
||||
RobotCode: "robot-123",
|
||||
Content: json.RawMessage(`{"downloadCode":"PCODE"}`),
|
||||
}
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeImage {
|
||||
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
|
||||
}
|
||||
// Pictures carry no filename; derive one from the message ID so the KB entry
|
||||
// has a real stem (matches the WeCom adapter behavior).
|
||||
if got.FileName != "pic-1.png" {
|
||||
t.Errorf("FileName = %q, want %q", got.FileName, "pic-1.png")
|
||||
}
|
||||
if got.FileKey != "PCODE" {
|
||||
t.Errorf("FileKey = %q, want %q", got.FileKey, "PCODE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_TextStillWorks(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "m2",
|
||||
Msgtype: "text",
|
||||
SenderStaffId: "staff-1",
|
||||
Text: &textContent{Content: " hello "},
|
||||
}
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeText {
|
||||
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
|
||||
}
|
||||
if got.Content != "hello" {
|
||||
t.Errorf("Content = %q, want %q", got.Content, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_RichTextPreservesTextAndPictureMetadata(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "rich-1",
|
||||
Msgtype: "richText",
|
||||
RobotCode: "robot-123",
|
||||
Content: json.RawMessage(`{
|
||||
"richText":[
|
||||
{"text":" 这个问题示例图如下: "},
|
||||
{"pictureDownloadCode":"PIC-FALLBACK","downloadCode":"PIC-CODE","type":"picture"},
|
||||
{"text":"通过以上示意图可以明白完整的交互流程"}
|
||||
]
|
||||
}`),
|
||||
}
|
||||
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeImage {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
|
||||
}
|
||||
if got.Content != "这个问题示例图如下:\n通过以上示意图可以明白完整的交互流程" {
|
||||
t.Errorf("Content = %q", got.Content)
|
||||
}
|
||||
if got.FileKey != "PIC-CODE" {
|
||||
t.Errorf("FileKey = %q, want PIC-CODE", got.FileKey)
|
||||
}
|
||||
if got.FileName != "rich-1.png" {
|
||||
t.Errorf("FileName = %q, want rich-1.png", got.FileName)
|
||||
}
|
||||
if got.Extra["raw_msgtype"] != "richText" {
|
||||
t.Errorf("raw_msgtype = %q, want richText", got.Extra["raw_msgtype"])
|
||||
}
|
||||
if got.Extra["rich_text_picture_count"] != "1" {
|
||||
t.Errorf("rich_text_picture_count = %q, want 1", got.Extra["rich_text_picture_count"])
|
||||
}
|
||||
if got.Extra["robot_code"] != "robot-123" {
|
||||
t.Errorf("robot_code = %q, want robot-123", got.Extra["robot_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_RichTextPictureOnlyUsesImagePath(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "rich-picture",
|
||||
Msgtype: "richText",
|
||||
Content: json.RawMessage(`{
|
||||
"richText":[{"pictureDownloadCode":"PIC-CODE","type":"picture"}]
|
||||
}`),
|
||||
}
|
||||
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeImage {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
|
||||
}
|
||||
if got.Content != "" {
|
||||
t.Errorf("Content = %q, want empty", got.Content)
|
||||
}
|
||||
if got.FileKey != "PIC-CODE" {
|
||||
t.Errorf("FileKey = %q, want PIC-CODE", got.FileKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_RichTextTextOnly(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "rich-text",
|
||||
Msgtype: "richText",
|
||||
Content: json.RawMessage(`{"richText":[{"text":" 只发文字 "}]}`),
|
||||
}
|
||||
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeText {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
|
||||
}
|
||||
if got.Content != "只发文字" {
|
||||
t.Errorf("Content = %q, want 只发文字", got.Content)
|
||||
}
|
||||
if got.FileKey != "" {
|
||||
t.Errorf("FileKey = %q, want empty", got.FileKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_RichTextOfficialPictureSample(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "official-rich",
|
||||
Msgtype: "richText",
|
||||
RobotCode: "robot-123",
|
||||
Content: json.RawMessage(`{
|
||||
"richText":[
|
||||
{"text":"你好"},
|
||||
{"downloadCode":"OFFICIAL-PIC","type":"picture"}
|
||||
]
|
||||
}`),
|
||||
}
|
||||
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeImage {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
|
||||
}
|
||||
if got.Content != "你好" {
|
||||
t.Errorf("Content = %q, want 你好", got.Content)
|
||||
}
|
||||
if got.FileKey != "OFFICIAL-PIC" {
|
||||
t.Errorf("FileKey = %q, want OFFICIAL-PIC", got.FileKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_RichTextKeepsFirstPictureAndHints(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "rich-multi",
|
||||
Msgtype: "richText",
|
||||
Content: json.RawMessage(`{
|
||||
"richText":[
|
||||
{"text":"对比这几张图"},
|
||||
{"downloadCode":"PIC-1","type":"picture"},
|
||||
{"downloadCode":"PIC-2","type":"picture"},
|
||||
{"downloadCode":"PIC-3","type":"picture"}
|
||||
]
|
||||
}`),
|
||||
}
|
||||
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.FileKey != "PIC-1" {
|
||||
t.Errorf("FileKey = %q, want PIC-1", got.FileKey)
|
||||
}
|
||||
if got.Extra["rich_text_picture_count"] != "3" {
|
||||
t.Errorf("rich_text_picture_count = %q, want 3", got.Extra["rich_text_picture_count"])
|
||||
}
|
||||
if !strings.Contains(got.Content, "对比这几张图") || !strings.Contains(got.Content, "共 3 张图片") {
|
||||
t.Errorf("Content = %q, want text plus dropped-picture hint", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_RichTextIgnoresDownloadCodeWithoutPictureType(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "rich-notype",
|
||||
Msgtype: "richText",
|
||||
Content: json.RawMessage(`{"richText":[{"text":"hello","downloadCode":"NOT-A-PIC"}]}`),
|
||||
}
|
||||
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeText {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
|
||||
}
|
||||
if got.FileKey != "" {
|
||||
t.Errorf("FileKey = %q, want empty", got.FileKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_RichTextEmptyOrInvalidFallsBackToEmptyText(t *testing.T) {
|
||||
empty := parseCallbackMessage(&callbackMessage{
|
||||
MsgID: "rich-empty",
|
||||
Msgtype: "richText",
|
||||
Content: json.RawMessage(`{"richText":[]}`),
|
||||
})
|
||||
if empty.MessageType != im.MessageTypeText || empty.Content != "" {
|
||||
t.Errorf("empty richText: type=%q content=%q", empty.MessageType, empty.Content)
|
||||
}
|
||||
|
||||
invalid := parseCallbackMessage(&callbackMessage{
|
||||
MsgID: "rich-bad",
|
||||
Msgtype: "richText",
|
||||
Content: json.RawMessage(`not-json`),
|
||||
})
|
||||
if invalid.MessageType != im.MessageTypeText || invalid.Content != "" {
|
||||
t.Errorf("invalid richText: type=%q content=%q", invalid.MessageType, invalid.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_AudioUsesRecognition(t *testing.T) {
|
||||
msg := &callbackMessage{
|
||||
MsgID: "audio-1",
|
||||
Msgtype: "audio",
|
||||
Content: json.RawMessage(`{"duration":4000,"downloadCode":"AUD-CODE","recognition":" 钉钉,让进步发生 "}`),
|
||||
}
|
||||
|
||||
got := parseCallbackMessage(msg)
|
||||
if got.MessageType != im.MessageTypeText {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
|
||||
}
|
||||
if got.Content != "钉钉,让进步发生" {
|
||||
t.Errorf("Content = %q, want recognition text", got.Content)
|
||||
}
|
||||
if got.Extra["raw_msgtype"] != "audio" {
|
||||
t.Errorf("raw_msgtype = %q, want audio", got.Extra["raw_msgtype"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallbackMessage_AudioWithoutRecognitionStaysEmpty(t *testing.T) {
|
||||
got := parseCallbackMessage(&callbackMessage{
|
||||
MsgID: "audio-empty",
|
||||
Msgtype: "audio",
|
||||
Content: json.RawMessage(`{"duration":1000,"downloadCode":"AUD-CODE"}`),
|
||||
})
|
||||
if got.MessageType != im.MessageTypeText || got.Content != "" {
|
||||
t.Errorf("audio without recognition: type=%q content=%q", got.MessageType, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToIncoming_File(t *testing.T) {
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
MsgId: "m3",
|
||||
Msgtype: "file",
|
||||
SenderStaffId: "staff-2",
|
||||
SenderNick: "Bob",
|
||||
ConversationType: "1",
|
||||
Content: map[string]interface{}{
|
||||
"fileName": "notes.docx",
|
||||
"downloadCode": "CODE2",
|
||||
},
|
||||
}
|
||||
got := streamToIncoming(data, "client-app-key")
|
||||
if got.MessageType != im.MessageTypeFile {
|
||||
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeFile)
|
||||
}
|
||||
if got.FileName != "notes.docx" {
|
||||
t.Errorf("FileName = %q, want %q", got.FileName, "notes.docx")
|
||||
}
|
||||
if got.FileKey != "CODE2" {
|
||||
t.Errorf("FileKey = %q, want %q", got.FileKey, "CODE2")
|
||||
}
|
||||
// Stream mode has no robotCode field; fall back to the app client ID.
|
||||
if got.Extra["robot_code"] != "client-app-key" {
|
||||
t.Errorf("Extra[robot_code] = %q, want %q", got.Extra["robot_code"], "client-app-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToIncoming_Picture(t *testing.T) {
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
MsgId: "spic-1",
|
||||
Msgtype: "picture",
|
||||
Content: map[string]interface{}{"downloadCode": "SPCODE"},
|
||||
}
|
||||
got := streamToIncoming(data, "client-app-key")
|
||||
if got.MessageType != im.MessageTypeImage {
|
||||
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
|
||||
}
|
||||
if got.FileName != "spic-1.png" {
|
||||
t.Errorf("FileName = %q, want %q", got.FileName, "spic-1.png")
|
||||
}
|
||||
if got.FileKey != "SPCODE" {
|
||||
t.Errorf("FileKey = %q, want %q", got.FileKey, "SPCODE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToIncoming_TextStillWorks(t *testing.T) {
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
MsgId: "m4",
|
||||
Msgtype: "text",
|
||||
SenderStaffId: "staff-2",
|
||||
Text: chatbot.BotCallbackDataTextModel{Content: " hi "},
|
||||
}
|
||||
got := streamToIncoming(data, "client-app-key")
|
||||
if got.MessageType != im.MessageTypeText {
|
||||
t.Errorf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
|
||||
}
|
||||
if got.Content != "hi" {
|
||||
t.Errorf("Content = %q, want %q", got.Content, "hi")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToIncoming_RichTextPreservesTextAndPictureMetadata(t *testing.T) {
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
MsgId: "stream-rich",
|
||||
Msgtype: "richText",
|
||||
Content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"text": "这种场景下,AI 去纹身去不干净"},
|
||||
map[string]interface{}{"downloadCode": "STREAM-PIC", "type": "picture"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := streamToIncoming(data, "client-app-key")
|
||||
if got.MessageType != im.MessageTypeImage {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeImage)
|
||||
}
|
||||
if got.Content != "这种场景下,AI 去纹身去不干净" {
|
||||
t.Errorf("Content = %q", got.Content)
|
||||
}
|
||||
if got.FileKey != "STREAM-PIC" {
|
||||
t.Errorf("FileKey = %q, want STREAM-PIC", got.FileKey)
|
||||
}
|
||||
if got.Extra["raw_msgtype"] != "richText" {
|
||||
t.Errorf("raw_msgtype = %q, want richText", got.Extra["raw_msgtype"])
|
||||
}
|
||||
if got.Extra["robot_code"] != "client-app-key" {
|
||||
t.Errorf("robot_code = %q, want client-app-key", got.Extra["robot_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToIncoming_AudioUsesRecognition(t *testing.T) {
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
MsgId: "stream-audio",
|
||||
Msgtype: "audio",
|
||||
Content: map[string]interface{}{
|
||||
"recognition": "语音转写结果",
|
||||
"downloadCode": "STREAM-AUD",
|
||||
},
|
||||
}
|
||||
|
||||
got := streamToIncoming(data, "client-app-key")
|
||||
if got.MessageType != im.MessageTypeText {
|
||||
t.Fatalf("MessageType = %q, want %q", got.MessageType, im.MessageTypeText)
|
||||
}
|
||||
if got.Content != "语音转写结果" {
|
||||
t.Errorf("Content = %q, want recognition text", got.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
)
|
||||
|
||||
func useTestHTTPClient(t *testing.T) {
|
||||
t.Helper()
|
||||
original := httpClient
|
||||
httpClient = &http.Client{Timeout: 5 * time.Second}
|
||||
t.Cleanup(func() { httpClient = original })
|
||||
}
|
||||
|
||||
// TestDownloadFile_EndToEnd drives the full DownloadFile orchestration against a
|
||||
// fake DingTalk OpenAPI: access-token fetch → messageFiles/download (downloadCode
|
||||
// → temporary downloadUrl) → GET the temp URL for the bytes. This covers the HTTP
|
||||
// path that the pure-function unit tests deliberately skip (issue #1771).
|
||||
func TestDownloadFile_EndToEnd(t *testing.T) {
|
||||
useTestHTTPClient(t)
|
||||
fileBytes := []byte("%PDF-1.7 fake product spec bytes")
|
||||
|
||||
var downloadReq map[string]string
|
||||
var tokenSeen string
|
||||
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1.0/oauth2/accessToken":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"accessToken": "tok-abc",
|
||||
"expireIn": 7200,
|
||||
})
|
||||
case "/v1.0/robot/messageFiles/download":
|
||||
tokenSeen = r.Header.Get("x-acs-dingtalk-access-token")
|
||||
_ = json.NewDecoder(r.Body).Decode(&downloadReq)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"downloadUrl": srv.URL + "/temp/file",
|
||||
})
|
||||
case "/temp/file":
|
||||
_, _ = w.Write(fileBytes)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
orig := apiBaseURL
|
||||
apiBaseURL = srv.URL
|
||||
defer func() { apiBaseURL = orig }()
|
||||
|
||||
origValidate := validateFileDownloadURL
|
||||
validateFileDownloadURL = func(string) error { return nil }
|
||||
defer func() { validateFileDownloadURL = origValidate }()
|
||||
|
||||
a := &Adapter{clientID: "cid", clientSecret: "sec"}
|
||||
msg := &im.IncomingMessage{
|
||||
MessageType: im.MessageTypeFile,
|
||||
FileKey: "DL-CODE",
|
||||
FileName: "spec.pdf",
|
||||
Extra: map[string]string{"robot_code": "rc-1"},
|
||||
}
|
||||
|
||||
reader, name, err := a.DownloadFile(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadFile error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(got) != string(fileBytes) {
|
||||
t.Errorf("downloaded bytes = %q, want %q", got, fileBytes)
|
||||
}
|
||||
if name != "spec.pdf" {
|
||||
t.Errorf("resolved name = %q, want %q", name, "spec.pdf")
|
||||
}
|
||||
if tokenSeen != "tok-abc" {
|
||||
t.Errorf("download request auth header = %q, want %q", tokenSeen, "tok-abc")
|
||||
}
|
||||
if downloadReq["robotCode"] != "rc-1" {
|
||||
t.Errorf("robotCode sent = %q, want %q", downloadReq["robotCode"], "rc-1")
|
||||
}
|
||||
if downloadReq["downloadCode"] != "DL-CODE" {
|
||||
t.Errorf("downloadCode sent = %q, want %q", downloadReq["downloadCode"], "DL-CODE")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadFile_TempURLError verifies a non-200 from the temporary download
|
||||
// URL surfaces as an error rather than silently returning empty content.
|
||||
func TestDownloadFile_TempURLError(t *testing.T) {
|
||||
useTestHTTPClient(t)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1.0/oauth2/accessToken":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accessToken": "tok", "expireIn": 7200})
|
||||
case "/v1.0/robot/messageFiles/download":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"downloadUrl": srv.URL + "/temp/gone"})
|
||||
case "/temp/gone":
|
||||
http.Error(w, "expired", http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
orig := apiBaseURL
|
||||
apiBaseURL = srv.URL
|
||||
defer func() { apiBaseURL = orig }()
|
||||
|
||||
origValidate := validateFileDownloadURL
|
||||
validateFileDownloadURL = func(string) error { return nil }
|
||||
defer func() { validateFileDownloadURL = origValidate }()
|
||||
|
||||
a := &Adapter{clientID: "cid", clientSecret: "sec"}
|
||||
msg := &im.IncomingMessage{FileKey: "DL-CODE", FileName: "x.pdf", Extra: map[string]string{"robot_code": "rc"}}
|
||||
|
||||
if _, _, err := a.DownloadFile(context.Background(), msg); err == nil {
|
||||
t.Errorf("expected error on non-200 download URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile_SSRFRejected(t *testing.T) {
|
||||
useTestHTTPClient(t)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1.0/oauth2/accessToken":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"accessToken": "tok", "expireIn": 7200})
|
||||
case "/v1.0/robot/messageFiles/download":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"downloadUrl": "http://127.0.0.1:1/internal"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
orig := apiBaseURL
|
||||
apiBaseURL = srv.URL
|
||||
defer func() { apiBaseURL = orig }()
|
||||
|
||||
a := &Adapter{clientID: "cid", clientSecret: "sec"}
|
||||
msg := &im.IncomingMessage{FileKey: "DL-CODE", FileName: "x.pdf", Extra: map[string]string{"robot_code": "rc"}}
|
||||
|
||||
if _, _, err := a.DownloadFile(context.Background(), msg); err == nil {
|
||||
t.Fatal("expected SSRF rejection error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedDingTalkDownloadHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
allow bool
|
||||
}{
|
||||
{"https://wukong-abc.oss-cn-hangzhou.aliyuncs.com/file?sig=x", true},
|
||||
{"https://api.dingtalk.com/temp/file", true},
|
||||
{"http://127.0.0.1:8080/file", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isAllowedDingTalkDownloadHost(tc.url); got != tc.allow {
|
||||
t.Errorf("isAllowedDingTalkDownloadHost(%q) = %v, want %v", tc.url, got, tc.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
)
|
||||
|
||||
// NewFactory returns an im.AdapterFactory for DingTalk channels.
|
||||
// Supports "webhook" and "websocket" (stream mode, default).
|
||||
func NewFactory() im.AdapterFactory {
|
||||
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
|
||||
creds, err := im.ParseCredentials(channel.Credentials)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse dingtalk credentials: %w", err)
|
||||
}
|
||||
|
||||
clientID := im.GetString(creds, "client_id")
|
||||
clientSecret := im.GetString(creds, "client_secret")
|
||||
cardTemplateID := im.GetString(creds, "card_template_id")
|
||||
|
||||
mode := im.ResolveMode(channel, "websocket")
|
||||
|
||||
switch mode {
|
||||
case "webhook":
|
||||
adapter := NewWebhookAdapter(clientID, clientSecret, cardTemplateID)
|
||||
return adapter, nil, nil
|
||||
|
||||
case "websocket":
|
||||
wsCtx, wsCancel := context.WithCancel(context.Background())
|
||||
go im.RunSupervised(wsCtx, im.SupervisorConfig{
|
||||
Name: fmt.Sprintf("DingTalk channel %s", channel.ID),
|
||||
Connect: func(ctx context.Context) (func(), error) {
|
||||
client := NewLongConnClient(clientID, clientSecret, msgHandler)
|
||||
if err := client.Start(ctx); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return client.Close, nil
|
||||
},
|
||||
})
|
||||
|
||||
adapter := NewAdapter(clientID, clientSecret, cardTemplateID)
|
||||
return adapter, wsCancel, nil
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported dingtalk mode: %s", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
dtsdk "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// MessageHandler is called when an IM message is received via stream connection.
|
||||
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
|
||||
|
||||
// LongConnClient manages a DingTalk Stream mode connection.
|
||||
type LongConnClient struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
handler MessageHandler
|
||||
streamClient *dtsdk.StreamClient
|
||||
}
|
||||
|
||||
// NewLongConnClient creates a DingTalk stream client.
|
||||
func NewLongConnClient(clientID, clientSecret string, handler MessageHandler) *LongConnClient {
|
||||
cli := dtsdk.NewStreamClient(
|
||||
dtsdk.WithAppCredential(&dtsdk.AppCredentialConfig{
|
||||
ClientId: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
}),
|
||||
)
|
||||
|
||||
c := &LongConnClient{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
handler: handler,
|
||||
streamClient: cli,
|
||||
}
|
||||
|
||||
cli.RegisterChatBotCallbackRouter(c.onChatBotMessage)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Start establishes the stream connection. The underlying SDK's Start is
|
||||
// non-blocking: it dials the websocket, spawns its internal read/reconnect
|
||||
// loops, and returns once the connection is established (or the attempt fails).
|
||||
func (c *LongConnClient) Start(ctx context.Context) error {
|
||||
logger.Infof(ctx, "[IM] DingTalk Stream connecting...")
|
||||
return c.streamClient.Start(ctx)
|
||||
}
|
||||
|
||||
// Close tears down the stream connection. AutoReconnect is disabled first so
|
||||
// that closing the connection does not trigger the SDK's internal reconnect.
|
||||
func (c *LongConnClient) Close() {
|
||||
c.streamClient.AutoReconnect = false
|
||||
c.streamClient.Close()
|
||||
}
|
||||
|
||||
func (c *LongConnClient) onChatBotMessage(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
incoming := streamToIncoming(data, c.clientID)
|
||||
|
||||
if err := c.handler(ctx, incoming); err != nil {
|
||||
logger.Errorf(ctx, "[DingTalk] Handle message error: %v", err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
Reference in New Issue
Block a user