Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 280 additions & 2 deletions pkg/channels/dingtalk/dingtalk.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
package dingtalk

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"

"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
Expand All @@ -18,6 +25,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
)

Expand All @@ -31,6 +39,9 @@ type DingTalkChannel struct {
streamClient *client.StreamClient
ctx context.Context
cancel context.CancelFunc
tokenMu sync.Mutex
accessToken string
tokenExpires time.Time
// Map to store session webhooks for each chat
sessionWebhooks sync.Map // chatID -> sessionWebhook
}
Expand Down Expand Up @@ -155,8 +166,28 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
}
}

// Check for image/picture messages before returning on empty content
var mediaRefs []string
if data.Content != nil {
if contentMap, ok := data.Content.(map[string]any); ok {
msgtype := stringValue(contentMap["msgtype"])
if msgtype == "picture" || msgtype == "image" {
refs, err := c.downloadInboundPicture(ctx, data)
if err != nil {
logger.ErrorCF("dingtalk", "Failed to download inbound picture", map[string]any{
"error": err.Error(),
})
}
if refs != nil {
mediaRefs = refs
content += " [image: photo]"
}
}
}
}

if content == "" {
return nil, nil // Ignore empty messages
return nil, nil // Ignore messages with neither text nor media
}

senderID := strings.TrimSpace(data.SenderStaffId)
Expand Down Expand Up @@ -245,13 +276,239 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
}
}

c.HandleInboundContext(ctx, chatID, content, nil, inboundCtx, sender)
c.HandleInboundContext(ctx, chatID, content, mediaRefs, inboundCtx, sender)

// Return nil to indicate we've handled the message asynchronously
// The response will be sent through the message bus
return nil, nil
}

// getAccessToken obtains and caches a DingTalk OpenAPI access token.
// Tokens are cached for their lifetime minus a 2-minute safety buffer.
func (c *DingTalkChannel) getAccessToken(ctx context.Context) (string, error) {
c.tokenMu.Lock()
defer c.tokenMu.Unlock()

// Return cached token if still valid (with 5-minute buffer)
if c.accessToken != "" && time.Now().Before(c.tokenExpires) {
return c.accessToken, nil
}

// Request new token from DingTalk OpenAPI
body := map[string]string{
"appKey": c.clientID,
"appSecret": c.clientSecret,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("failed to marshal token request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token request returned %d: %s", resp.StatusCode, string(respBody))
}

var result struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"` // seconds
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
}

if result.AccessToken == "" {
return "", fmt.Errorf("empty access token in response")
}

// Cache with 120s safety buffer
expireDuration := time.Duration(result.ExpireIn) * time.Second
c.accessToken = result.AccessToken
c.tokenExpires = time.Now().Add(expireDuration - 120*time.Second)

logger.DebugC("dingtalk", "Obtained new access token")
return c.accessToken, nil
}

// downloadInboundPicture downloads a picture message from DingTalk,
// saves it to the media temp directory, registers it in the MediaStore,
// and returns the media ref string. Failures degrade gracefully.
func (c *DingTalkChannel) downloadInboundPicture(
ctx context.Context,
data *chatbot.BotCallbackDataModel,
) ([]string, error) {
contentMap, ok := data.Content.(map[string]any)
if !ok {
return nil, nil
}

downloadCode := stringValue(contentMap["downloadCode"])
robotCode := stringValue(contentMap["robotCode"])
if downloadCode == "" || robotCode == "" {
logger.WarnCF("dingtalk", "Inbound picture missing downloadCode or robotCode", map[string]any{
"keys": keysOf(contentMap),
})
return nil, nil
}

// Get access token
token, err := c.getAccessToken(ctx)
if err != nil {
logger.ErrorCF("dingtalk", "Failed to get access token for picture download", map[string]any{
"error": err.Error(),
})
return nil, nil
}

// Call DingTalk API to get download URL
reqBody := map[string]string{
"downloadCode": downloadCode,
"robotCode": robotCode,
}
reqBytes, err := json.Marshal(reqBody)
if err != nil {
logger.ErrorCF("dingtalk", "Failed to marshal download request", map[string]any{
"error": err.Error(),
})
return nil, nil
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.dingtalk.com/v1.0/robot/messageFiles/download",
bytes.NewReader(reqBytes))
if err != nil {
logger.ErrorCF("dingtalk", "Failed to create download request", map[string]any{
"error": err.Error(),
})
return nil, nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)

resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.ErrorCF("dingtalk", "Download URL request failed", map[string]any{
"error": err.Error(),
})
return nil, nil
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
logger.ErrorCF("dingtalk", "Download URL request returned non-OK", map[string]any{
"status": resp.StatusCode,
"body": string(respBody),
})
return nil, nil
}

var downloadResult struct {
DownloadURL string `json:"downloadUrl"`
}
if err := json.NewDecoder(resp.Body).Decode(&downloadResult); err != nil {
logger.ErrorCF("dingtalk", "Failed to decode download URL response", map[string]any{
"error": err.Error(),
})
return nil, nil
}

if downloadResult.DownloadURL == "" {
logger.WarnC("dingtalk", "Empty download URL in response")
return nil, nil
}

// Download the actual file content
fileResp, err := http.Get(downloadResult.DownloadURL)
if err != nil {
logger.ErrorCF("dingtalk", "Failed to download picture file", map[string]any{
"error": err.Error(),
})
return nil, nil
}
defer fileResp.Body.Close()

fileBytes, err := io.ReadAll(fileResp.Body)
if err != nil {
logger.ErrorCF("dingtalk", "Failed to read picture file", map[string]any{
"error": err.Error(),
})
return nil, nil
}

// Determine file extension from Content-Type
contentType := fileResp.Header.Get("Content-Type")
ext := ".jpg"
if strings.Contains(contentType, "png") {
ext = ".png"
} else if strings.Contains(contentType, "gif") {
ext = ".gif"
} else if strings.Contains(contentType, "webp") {
ext = ".webp"
} else if strings.Contains(contentType, "bmp") {
ext = ".bmp"
}

// Write to temp file
mediaDir := media.TempDir()
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
logger.ErrorCF("dingtalk", "Failed to create media directory", map[string]any{
"error": err.Error(),
})
return nil, nil
}

filename := fmt.Sprintf("dingtalk_%s%s", downloadCode, ext)
localPath := filepath.Join(mediaDir, utils.SanitizeFilename(filename))
if err := os.WriteFile(localPath, fileBytes, 0o644); err != nil {
logger.ErrorCF("dingtalk", "Failed to write picture file", map[string]any{
"error": err.Error(),
})
return nil, nil
}

// Register in MediaStore
store := c.GetMediaStore()
if store == nil {
logger.WarnC("dingtalk", "No MediaStore available, skipping media registration")
return nil, nil
}

scope := channels.BuildMediaScope("dingtalk", "", downloadCode)
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
ContentType: contentType,
Source: "dingtalk",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err != nil {
logger.ErrorCF("dingtalk", "Failed to store picture in MediaStore", map[string]any{
"error": err.Error(),
})
os.Remove(localPath)
return nil, nil
}

logger.DebugCF("dingtalk", "Downloaded and stored inbound picture", map[string]any{
"ref": ref,
})
return []string{ref}, nil
}

// SendDirectReply sends a direct reply using the session webhook
func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error {
replier := chatbot.NewChatbotReplier()
Expand All @@ -274,6 +531,7 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
return nil
}

// stripLeadingAtMentions removes leading @mentions from the message content
func stripLeadingAtMentions(content string) string {
fields := strings.Fields(content)
if len(fields) == 0 {
Expand All @@ -289,3 +547,23 @@ func stripLeadingAtMentions(content string) string {
}
return strings.Join(fields[i:], " ")
}

// keysOf returns all keys from a map for debugging/logging purposes.
func keysOf(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

// stringValue safely extracts a string from an interface{} value.
func stringValue(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}