Files

1622 lines
48 KiB
Go

package dingtalk
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"mime/multipart"
"net/http"
"os/exec"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
dingtalkClient "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/utils"
)
func init() {
core.RegisterPlatform("dingtalk", New)
}
type replyContext struct {
sessionWebhook string
conversationId string
senderStaffId string
isGroup bool
proactive bool // true when constructed by ReconstructReplyCtx (no sessionWebhook)
msgId string // message ID, used for emotion reactions
}
// richTextContent mirrors the full structure of the DingTalk "text" JSON field,
// which the Go SDK's BotCallbackDataTextModel (Content string) silently drops.
// When a user quotes/replies to a message, DingTalk sends isReplyMsg + repliedMsg.
type richTextContent struct {
Content string `json:"content"`
IsReplyMsg bool `json:"isReplyMsg"`
RepliedMsg *repliedMessage `json:"repliedMsg"`
}
type repliedMessage struct {
MsgType string `json:"msgType"`
Content json.RawMessage `json:"content"`
}
type repliedTextContent struct {
Text string `json:"text"`
}
const maxQuotedMessageRunes = 4000
type downloadResponse struct {
DownloadUrl string `json:"downloadUrl"`
}
type Platform struct {
clientID string
clientSecret string
robotCode string
agentID int64 // Agent ID for work notifications API (numeric)
allowFrom string
requireMention bool
shareSessionInChannel bool
streamClient *dingtalkClient.StreamClient
streamCtxCancel context.CancelFunc
handler core.MessageHandler
dedup core.MessageDedup
httpClient *http.Client
tokenMu sync.Mutex
accessToken string
tokenExpiry time.Time
// AI Card configuration
cardTemplateID string
cardTemplateKey string
cardThrottleMs int
degradeUntil time.Time
degradeMu sync.Mutex
// Admin tag configuration
adminUsers map[string]bool // user IDs that get "[管理员]" tag
}
func New(opts map[string]any) (core.Platform, error) {
clientID, _ := opts["client_id"].(string)
clientSecret, _ := opts["client_secret"].(string)
robotCode, _ := opts["robot_code"].(string)
allowFrom, _ := opts["allow_from"].(string)
core.CheckAllowFrom("dingtalk", allowFrom)
requireMention, _ := opts["require_mention"].(bool)
fmt.Println("---- [debug], requireMention= ", requireMention)
shareSessionInChannel, _ := opts["share_session_in_channel"].(bool)
if clientID == "" || clientSecret == "" {
return nil, fmt.Errorf("dingtalk: client_id and client_secret are required")
}
if robotCode == "" {
robotCode = clientID // fallback to client_id if robot_code not specified
}
// Validate robot_code format (should not be empty after fallback)
if robotCode == "" {
return nil, fmt.Errorf("dingtalk: robot_code is required (or client_id)")
}
// agent_id is required for work notifications API (numeric type)
// Try to read as int64 first, then float64 (JSON numbers), fallback to 0
var agentID int64
if v, ok := opts["agent_id"].(int64); ok {
agentID = v
} else if v, ok := opts["agent_id"].(float64); ok {
agentID = int64(v)
} else if v, ok := opts["agent_id"].(int); ok {
agentID = int64(v)
}
// agent_id can be 0 for testing, but will fail in production
// AI Card configuration
cardTemplateID, _ := opts["card_template_id"].(string)
cardTemplateKey, _ := opts["card_template_key"].(string)
if cardTemplateKey == "" {
cardTemplateKey = "content"
}
cardThrottleMs := 300
if v, ok := opts["card_throttle_ms"].(float64); ok && v > 0 {
cardThrottleMs = int(v)
} else if v, ok := opts["card_throttle_ms"].(int64); ok && v > 0 {
cardThrottleMs = int(v)
} else if v, ok := opts["card_throttle_ms"].(int); ok && v > 0 {
cardThrottleMs = v
}
// Parse admin user list
adminUsers := make(map[string]bool)
if raw, ok := opts["admin_users"]; ok {
if list, ok := raw.([]any); ok {
for _, id := range list {
if s, ok := id.(string); ok && s != "" {
adminUsers[s] = true
}
}
}
}
return &Platform{
clientID: clientID,
clientSecret: clientSecret,
robotCode: robotCode,
agentID: agentID,
allowFrom: allowFrom,
requireMention: requireMention,
shareSessionInChannel: shareSessionInChannel,
httpClient: &http.Client{Timeout: 30 * time.Second},
cardTemplateID: cardTemplateID,
cardTemplateKey: cardTemplateKey,
cardThrottleMs: cardThrottleMs,
adminUsers: adminUsers,
}, nil
}
func (p *Platform) Name() string { return "dingtalk" }
func (p *Platform) Start(handler core.MessageHandler) error {
p.handler = handler
p.streamClient = dingtalkClient.NewStreamClient(
dingtalkClient.WithAppCredential(dingtalkClient.NewAppCredentialConfig(p.clientID, p.clientSecret)),
)
// Register a raw frame handler instead of RegisterChatBotCallbackRouter so we
// can access the original JSON (df.Data). The SDK's BotCallbackDataModel drops
// fields like text.isReplyMsg and text.repliedMsg during deserialization.
p.streamClient.RegisterRouter(utils.SubscriptionTypeKCallback, payload.BotMessageCallbackTopic,
func(ctx context.Context, df *payload.DataFrame) (*payload.DataFrameResponse, error) {
p.onRawMessage(df.Data)
return payload.NewSuccessDataFrameResponse(), nil
})
ctx, cancel := context.WithCancel(context.Background())
p.streamCtxCancel = cancel
// Run the stream in a restart loop. The SDK's processLoop() runs in a background
// goroutine and handles keepalive pings internally. If the goroutine exits
// (e.g. server closes idle connection), Start() returns and we attempt to reconnect.
// This ensures the bot stays connected even after long periods of silence.
go func() {
defer slog.Info("dingtalk: stream runner exited")
for {
select {
case <-ctx.Done():
return
default:
}
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("dingtalk: stream panic, reconnecting", "recover", r)
}
}()
if err := p.streamClient.Start(ctx); err != nil {
slog.Warn("dingtalk: stream disconnected, reconnecting", "error", err)
}
}()
// Brief pause before reconnecting to avoid tight loop on persistent failures.
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
}
}()
slog.Info("dingtalk: stream connected", "client_id", p.clientID)
return nil
}
// onRawMessage is the entry point for incoming messages. It receives the raw
// JSON from the DingTalk Stream SDK (df.Data) and parses it into the SDK's
// BotCallbackDataModel plus our own richTextContent to recover fields that
// the SDK's typed model silently drops (isReplyMsg, repliedMsg).
func (p *Platform) onRawMessage(rawJSON string) {
var data chatbot.BotCallbackDataModel
if err := json.Unmarshal([]byte(rawJSON), &data); err != nil {
slog.Error("dingtalk: failed to parse callback data", "error", err)
return
}
// Parse the full "text" object from raw JSON to recover isReplyMsg/repliedMsg.
// The SDK's BotCallbackDataTextModel only has Content string, losing these fields.
var envelope struct {
Text richTextContent `json:"text"`
}
if err := json.Unmarshal([]byte(rawJSON), &envelope); err != nil {
slog.Warn("dingtalk: failed to parse rich text content", "error", err)
}
p.onMessage(&data, &envelope.Text)
}
func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richTextContent) {
slog.Debug("dingtalk: message received", "user", data.SenderNick, "msgtype", data.Msgtype)
if p.dedup.IsDuplicate(data.MsgId) {
slog.Debug("dingtalk: duplicate message ignored", "msg_id", data.MsgId)
return
}
if data.CreateAt > 0 {
msgTime := time.Unix(data.CreateAt/1000, (data.CreateAt%1000)*int64(time.Millisecond))
if core.IsOldMessage(msgTime) {
slog.Debug("dingtalk: ignoring old message after restart", "create_at", data.CreateAt)
return
}
}
if !core.AllowList(p.allowFrom, data.SenderStaffId) {
slog.Debug("dingtalk: message from unauthorized user", "user", data.SenderStaffId)
return
}
// In group chats, only respond when the bot is @mentioned (if require_mention is set).
if p.requireMention && data.ConversationType == "2" && !data.IsInAtList {
slog.Debug("dingtalk: ignoring group message without @mention", "user", data.SenderNick)
return
}
convType := "d" // direct (1:1)
if data.ConversationType == "2" {
convType = "g" // group
}
var sessionKey string
if p.shareSessionInChannel {
sessionKey = fmt.Sprintf("dingtalk:%s:%s", convType, data.ConversationId)
} else {
sessionKey = fmt.Sprintf("dingtalk:%s:%s:%s", convType, data.ConversationId, data.SenderStaffId)
}
// Handle audio messages
if data.Msgtype == "audio" {
p.handleAudioMessage(data, sessionKey)
return
}
// 告诉ai该用户是否是管理员
userName := data.SenderNick
if p.adminUsers[data.SenderStaffId] {
userName += "[管理员]"
}
// Handle richText messages — extract plain text and images from rich content
if data.Msgtype == "richText" {
text, imageCodes := extractRichText(data.Content)
if text == "" && len(imageCodes) == 0 {
slog.Debug("dingtalk: richText message with no extractable content", "msg_id", data.MsgId)
return
}
// Download images from downloadCode
var images []core.ImageAttachment
for _, code := range imageCodes {
imgData, mimeType, err := p.downloadImageByCode(code)
if err != nil {
slog.Error("dingtalk: failed to download richText image", "error", err)
continue
}
images = append(images, core.ImageAttachment{
MimeType: mimeType,
Data: imgData,
})
}
msg := &core.Message{
SessionKey: sessionKey,
Platform: "dingtalk",
UserID: data.SenderStaffId,
UserName: userName,
ChatName: data.ConversationTitle,
Content: text,
MessageID: data.MsgId,
Images: images,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
msgId: data.MsgId,
},
}
p.handler(p, msg)
return
}
// Handle image messages
// DingTalk delivers image messages as either "image" or "picture" depending
// on the client and robot type. Both carry the same downloadCode field.
if data.Msgtype == "image" || data.Msgtype == "picture" {
p.handleImageMessage(data, sessionKey)
return
}
// Extract message content, recovering quoted/reply info from richText.
messageContent := data.Text.Content
if richText != nil && richText.IsReplyMsg && richText.RepliedMsg != nil {
slog.Debug("dingtalk: reply message detected", "msgType", richText.RepliedMsg.MsgType)
messageContent = p.formatReplyContent(richText, messageContent)
}
// Handle text messages (default)
msg := &core.Message{
SessionKey: sessionKey,
Platform: "dingtalk",
UserID: data.SenderStaffId,
UserName: userName,
ChatName: data.ConversationTitle,
Content: messageContent,
MessageID: data.MsgId,
ChannelKey: data.ConversationId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
msgId: data.MsgId,
},
}
p.handler(p, msg)
}
// extractRichText extracts plain text and image download codes from a DingTalk richText content payload.
// The expected structure is: {"richText": [{"text": "..."}, {"downloadCode": "...", "pictureDownloadCode": "...", "type": "picture"}, ...]}
// Returns text and a list of download codes for images found in the rich text.
// Prefers "downloadCode" over "pictureDownloadCode" (the former is used by the messageFiles/download API).
func extractRichText(content interface{}) (string, []string) {
m, ok := content.(map[string]interface{})
if !ok {
return "", nil
}
parts, ok := m["richText"].([]interface{})
if !ok {
return "", nil
}
var b strings.Builder
var imageCodes []string
for _, part := range parts {
item, ok := part.(map[string]interface{})
if !ok {
continue
}
if text, ok := item["text"].(string); ok {
b.WriteString(text)
}
// Prefer downloadCode (used by handleImageMessage and messageFiles/download API).
// Fall back to pictureDownloadCode if downloadCode is not present.
code, _ := item["downloadCode"].(string)
if code == "" {
code, _ = item["pictureDownloadCode"].(string)
}
if code != "" {
imageCodes = append(imageCodes, code)
}
}
return strings.TrimSpace(b.String()), imageCodes
}
func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessionKey string) {
slog.Debug("dingtalk: audio message received", "user", data.SenderNick)
// Parse audio content from the raw content
audioData, ok := data.Content.(map[string]interface{})
if !ok {
slog.Error("dingtalk: invalid audio content type", "type", fmt.Sprintf("%T", data.Content))
return
}
downloadCode, _ := audioData["downloadCode"].(string)
recognition, _ := audioData["recognition"].(string)
if downloadCode == "" {
slog.Error("dingtalk: audio message missing downloadCode")
return
}
// Download audio file
audioBytes, mimeType, err := p.downloadAudio(downloadCode)
if err != nil {
slog.Error("dingtalk: failed to download audio", "error", err)
// Fallback to recognition text if available
if recognition != "" {
userName := data.SenderNick
if p.adminUsers[data.SenderStaffId] {
userName += "[管理员]"
}
msg := &core.Message{
SessionKey: sessionKey,
Platform: "dingtalk",
UserID: data.SenderStaffId,
UserName: userName,
Content: recognition,
MessageID: data.MsgId,
ChannelKey: data.ConversationId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
msgId: data.MsgId,
},
FromVoice: true,
}
p.handler(p, msg)
}
return
}
slog.Info("dingtalk: audio downloaded successfully", "size", len(audioBytes), "mime", mimeType)
// Create message with audio attachment
msg := &core.Message{
SessionKey: sessionKey,
Platform: "dingtalk",
UserID: data.SenderStaffId,
UserName: data.SenderNick,
Content: recognition, // Use recognition as text content
MessageID: data.MsgId,
ChannelKey: data.ConversationId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
msgId: data.MsgId,
},
FromVoice: true,
Audio: &core.AudioAttachment{
MimeType: mimeType,
Data: audioBytes,
Format: "amr", // DingTalk typically uses AMR format
},
}
p.handler(p, msg)
}
func (p *Platform) handleImageMessage(data *chatbot.BotCallbackDataModel, sessionKey string) {
slog.Debug("dingtalk: image message received", "user", data.SenderNick)
// Parse image content from the raw content
imageData, ok := data.Content.(map[string]interface{})
if !ok {
slog.Error("dingtalk: invalid image content type", "type", fmt.Sprintf("%T", data.Content))
return
}
downloadCode, _ := imageData["downloadCode"].(string)
if downloadCode == "" {
slog.Error("dingtalk: image message missing downloadCode")
return
}
// Download image file using the same messageFiles/download API as audio
downloadURL, err := p.getDownloadURL(downloadCode)
if err != nil {
slog.Error("dingtalk: failed to get image download URL", "error", err)
return
}
resp, err := p.httpClient.Get(downloadURL)
if err != nil {
slog.Error("dingtalk: failed to download image", "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Error("dingtalk: image download returned status", "status", resp.StatusCode)
return
}
const maxImageBytes = 25 * 1024 * 1024 // 25 MiB, same cap as other platforms
imgBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
if err != nil {
slog.Error("dingtalk: failed to read image data", "error", err)
return
}
if len(imgBytes) > maxImageBytes {
slog.Error("dingtalk: image too large, dropping", "size", len(imgBytes), "limit", maxImageBytes)
return
}
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "image/png"
}
slog.Info("dingtalk: image downloaded successfully", "size", len(imgBytes), "mime", mimeType)
msg := &core.Message{
SessionKey: sessionKey,
Platform: "dingtalk",
UserID: data.SenderStaffId,
UserName: data.SenderNick,
MessageID: data.MsgId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
msgId: data.MsgId,
},
Images: []core.ImageAttachment{{
MimeType: mimeType,
Data: imgBytes,
}},
}
p.handler(p, msg)
}
// downloadImageByCode downloads an image using a downloadCode from richText messages.
func (p *Platform) downloadImageByCode(downloadCode string) ([]byte, string, error) {
downloadURL, err := p.getDownloadURL(downloadCode)
if err != nil {
return nil, "", fmt.Errorf("get download URL: %w", err)
}
resp, err := p.httpClient.Get(downloadURL)
if err != nil {
return nil, "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("download returned status %d", resp.StatusCode)
}
const maxImageBytes = 25 * 1024 * 1024
data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
if err != nil {
return nil, "", fmt.Errorf("read response: %w", err)
}
if len(data) > maxImageBytes {
return nil, "", fmt.Errorf("image too large: %d bytes", len(data))
}
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "image/png"
}
return data, mimeType, nil
}
func (p *Platform) downloadAudio(downloadCode string) ([]byte, string, error) {
// Get download URL
downloadURL, err := p.getDownloadURL(downloadCode)
if err != nil {
return nil, "", fmt.Errorf("get download URL: %w", err)
}
// Download audio file
resp, err := p.httpClient.Get(downloadURL)
if err != nil {
return nil, "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("download returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("read response: %w", err)
}
// Determine MIME type from Content-Type header
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "audio/amr" // Default to AMR if not specified
}
return data, mimeType, nil
}
func (p *Platform) getDownloadURL(downloadCode string) (string, error) {
token, err := p.getAccessToken()
if err != nil {
return "", fmt.Errorf("get access token: %w", err)
}
reqBody := map[string]string{
"downloadCode": downloadCode,
"robotCode": p.robotCode,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.dingtalk.com/v1.0/robot/messageFiles/download",
bytes.NewReader(bodyBytes))
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 := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("api returned status %d: %s", resp.StatusCode, string(respBody))
}
var result downloadResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if result.DownloadUrl == "" {
return "", fmt.Errorf("empty downloadUrl in response")
}
return result.DownloadUrl, nil
}
func (p *Platform) getAccessToken() (string, error) {
p.tokenMu.Lock()
defer p.tokenMu.Unlock()
// Return cached token if still valid
if p.accessToken != "" && time.Now().Before(p.tokenExpiry) {
return p.accessToken, nil
}
// Request new access token using DingTalk's new API (api.dingtalk.com/v1.0/oauth2/accessToken)
// This requires POST request with JSON body
url := "https://api.dingtalk.com/v1.0/oauth2/accessToken"
reqBody := map[string]string{
"appKey": p.clientID,
"appSecret": p.clientSecret,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("api returned status %d: %s", resp.StatusCode, body)
}
var tokenResp struct {
AccessToken string `json:"accessToken"`
ExpireIn int `json:"expireIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("empty accessToken in response")
}
// Cache token with 5 minutes buffer before expiry.
// When the server omits expireIn (or sends 0/negative), fall back to the
// documented DingTalk default (7200s = 2h) — without this, tokenExpiry
// would land at time.Now() and every subsequent getAccessToken() would
// re-fetch a fresh token, hammering the access-token API.
p.accessToken = tokenResp.AccessToken
expiry := tokenResp.ExpireIn
if expiry <= 0 {
slog.Warn("dingtalk: missing/invalid expireIn in token response, defaulting to 7200s", "got", tokenResp.ExpireIn)
expiry = 7200
}
if expiry > 300 {
expiry -= 300 // 5 minute buffer
}
p.tokenExpiry = time.Now().Add(time.Duration(expiry) * time.Second)
slog.Debug("dingtalk: access token refreshed", "expires_at", p.tokenExpiry)
return p.accessToken, nil
}
func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("dingtalk: invalid reply context type %T", rctx)
}
// Fall back to proactive API when sessionWebhook is unavailable
if rc.proactive || rc.sessionWebhook == "" {
return p.sendProactiveMessage(ctx, rc, content)
}
content = preprocessDingTalkMarkdown(content)
payload := map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{"title": "reply", "text": content},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("dingtalk: marshal reply: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rc.sessionWebhook, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("dingtalk: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := core.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("dingtalk: send reply: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("dingtalk: reply returned status %d", resp.StatusCode)
}
return nil
}
// Send sends a new message. For proactive contexts (no sessionWebhook),
// it uses the DingTalk group/direct message API instead.
func (p *Platform) Send(ctx context.Context, rctx any, content string) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("dingtalk: invalid reply context type %T", rctx)
}
if rc.proactive || rc.sessionWebhook == "" {
return p.sendProactiveMessage(ctx, rc, content)
}
return p.Reply(ctx, rctx, content)
}
// SendImage uploads and sends an image via DingTalk oToMessages API.
// Implements core.ImageSender.
func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttachment) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("dingtalk: SendImage: invalid reply context type %T", rctx)
}
name := img.FileName
if name == "" {
name = "image.png"
}
mediaID, err := p.uploadMedia(ctx, img.Data, name, "image")
if err != nil {
return fmt.Errorf("dingtalk: upload image: %w", err)
}
slog.Debug("dingtalk: image uploaded", "media_id", mediaID, "size", len(img.Data))
token, err := p.getAccessToken()
if err != nil {
return fmt.Errorf("dingtalk: get access token: %w", err)
}
msgParamBytes, _ := json.Marshal(map[string]string{"photoURL": mediaID})
requestBody := map[string]any{
"robotCode": p.robotCode,
"userIds": []string{rc.senderStaffId},
"msgKey": "sampleImageMsg",
"msgParam": string(msgParamBytes),
}
body, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("dingtalk: marshal image message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("dingtalk: create image request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("dingtalk: send image request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("dingtalk: oToMessages image response", "status", resp.StatusCode, "body", string(respBody))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("dingtalk: send image failed: status=%d, body=%s", resp.StatusCode, string(respBody))
}
slog.Info("dingtalk: image message sent", "media_id", mediaID, "user", rc.senderStaffId)
return nil
}
var _ core.ImageSender = (*Platform)(nil)
var _ core.StreamingCardPlatform = (*Platform)(nil)
var _ core.ReplyContextReconstructor = (*Platform)(nil)
var _ core.TypingIndicator = (*Platform)(nil)
var _ core.TypingIndicatorDone = (*Platform)(nil)
// CreateStreamingCard creates a new streaming card for the given reply context.
// Implements core.StreamingCardPlatform.
func (p *Platform) CreateStreamingCard(ctx context.Context, replyCtx any) (core.StreamingCard, error) {
if p.cardTemplateID == "" {
return nil, fmt.Errorf("dingtalk: card_template_id not configured")
}
if p.isCardDegraded() {
return nil, fmt.Errorf("dingtalk: card API temporarily degraded")
}
rc, ok := replyCtx.(replyContext)
if !ok {
return nil, fmt.Errorf("dingtalk: invalid reply context type %T", replyCtx)
}
return p.createAICard(ctx, rc)
}
// SendFile uploads and sends a file via DingTalk oToMessages API.
// Implements core.FileSender.
func (p *Platform) SendFile(ctx context.Context, rctx any, file core.FileAttachment) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("dingtalk: SendFile: invalid reply context type %T", rctx)
}
name := file.FileName
if name == "" {
name = "file"
}
mediaID, err := p.uploadMedia(ctx, file.Data, name, "file")
if err != nil {
return fmt.Errorf("dingtalk: upload file: %w", err)
}
slog.Debug("dingtalk: file uploaded", "media_id", mediaID, "name", name, "size", len(file.Data))
token, err := p.getAccessToken()
if err != nil {
return fmt.Errorf("dingtalk: get access token: %w", err)
}
ext := ""
if idx := strings.LastIndex(name, "."); idx >= 0 {
ext = name[idx+1:]
}
msgParamBytes, _ := json.Marshal(map[string]string{
"mediaId": mediaID,
"fileName": name,
"fileType": ext,
})
requestBody := map[string]any{
"robotCode": p.robotCode,
"userIds": []string{rc.senderStaffId},
"msgKey": "sampleFile",
"msgParam": string(msgParamBytes),
}
body, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("dingtalk: marshal file message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("dingtalk: create file request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("dingtalk: send file request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("dingtalk: oToMessages file response", "status", resp.StatusCode, "body", string(respBody))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("dingtalk: send file failed: status=%d, body=%s", resp.StatusCode, string(respBody))
}
slog.Info("dingtalk: file message sent", "media_id", mediaID, "name", name, "user", rc.senderStaffId)
return nil
}
var _ core.FileSender = (*Platform)(nil)
// SendAudio uploads audio bytes to DingTalk and sends a voice message.
// Implements core.AudioSender interface.
// Uses DingTalk oToMessages API with msgKey: "sampleAudio" (voice messages).
// DingTalk voice messages only support ogg/amr formats (not mp3).
func (p *Platform) SendAudio(ctx context.Context, rctx any, audio []byte, format string) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("dingtalk: SendAudio: invalid reply context type %T", rctx)
}
slog.Debug("dingtalk: SendAudio called", "format", format, "size", len(audio), "conversation_id", rc.conversationId)
// Convert MP3 to OGG if needed (DingTalk voice messages only support ogg/amr)
if strings.ToLower(format) == "mp3" {
slog.Debug("dingtalk: converting MP3 to OGG format (DingTalk requirement)")
oggAudio, err := core.ConvertMP3ToOGG(ctx, audio)
if err != nil {
slog.Warn("dingtalk: MP3 to OGG conversion failed", "error", err)
// Fallback: try AMR format instead
amrAudio, err := core.ConvertMP3ToAMR(ctx, audio)
if err != nil {
return fmt.Errorf("dingtalk: convert MP3 to AMR failed: %w", err)
}
audio = amrAudio
format = "amr"
} else {
audio = oggAudio
format = "ogg"
}
slog.Debug("dingtalk: audio converted", "new_format", format, "new_size", len(audio))
}
// Compress audio if too large (DingTalk limit is 2MB)
const maxAudioSize = 2 * 1024 * 1024
if len(audio) > maxAudioSize {
slog.Debug("dingtalk: audio too large, compressing", "size", len(audio), "max", maxAudioSize)
compressed, compressedFormat, err := p.compressAudio(ctx, audio, format)
if err != nil {
slog.Warn("dingtalk: compression failed, using original", "error", err)
} else {
audio = compressed
format = compressedFormat
slog.Debug("dingtalk: audio compressed", "new_size", len(audio), "new_format", format)
}
}
// Upload audio to DingTalk media API
mediaID, err := p.uploadMedia(ctx, audio, fmt.Sprintf("audio.%s", format), "voice")
if err != nil {
return fmt.Errorf("dingtalk: upload audio: %w", err)
}
slog.Debug("dingtalk: audio uploaded", "media_id", mediaID, "format", format, "size", len(audio))
// Calculate duration from audio size (rough estimate based on bitrate)
// NOTE: This is an approximation. For accurate duration, consider using ffprobe or go-audio library.
// OGG (Opus 64kbps): ~8KB/sec, AMR-NB (12.2kbps): ~4KB/sec, MP3 (128kbps): ~16KB/sec
var duration int
if format == "ogg" {
duration = len(audio) / 8000
} else if format == "amr" {
duration = len(audio) / 4000
} else if format == "mp3" {
duration = len(audio) / 16000
} else {
duration = len(audio) / 32000
}
if duration == 0 {
duration = 1
}
durationMs := duration * 1000
// Use oToMessages API with msgKey: "sampleAudio" for voice messages
// This is the official API for sending voice messages in bot conversations
token, err := p.getAccessToken()
if err != nil {
return fmt.Errorf("dingtalk: get access token: %w", err)
}
// Build oToMessages API request with sampleAudio msgKey
// msgParam must be a JSON string, not an object
msgParamJSON := fmt.Sprintf(`{"mediaId":"%s","duration":"%d"}`, mediaID, durationMs)
requestBody := map[string]interface{}{
"robotCode": p.robotCode,
"userIds": []string{rc.senderStaffId},
"msgKey": "sampleAudio",
"msgParam": msgParamJSON,
}
body, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("dingtalk: marshal audio message: %w", err)
}
slog.Debug("dingtalk: sending voice via oToMessages API", "media_id", mediaID, "duration", durationMs, "user_id", rc.senderStaffId)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("dingtalk: create audio request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("dingtalk: send audio request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("dingtalk: oToMessages API response", "status", resp.StatusCode, "body", string(respBody))
if resp.StatusCode != 200 {
return fmt.Errorf("dingtalk: send audio failed: status=%d, body=%s", resp.StatusCode, string(respBody))
}
slog.Info("dingtalk: voice message sent successfully", "media_id", mediaID, "conversation_id", rc.conversationId)
return nil
}
// compressAudio compresses audio if it exceeds size limits.
// Uses ffmpeg to convert WAV to MP3 format (DingTalk supported, ~10:1 compression ratio).
func (p *Platform) compressAudio(ctx context.Context, audio []byte, format string) ([]byte, string, error) {
// Only WAV format can be compressed to MP3
if strings.ToLower(format) != "wav" {
return nil, "", fmt.Errorf("only WAV format can be compressed, got: %s", format)
}
return p.compressAudioWithFFmpeg(ctx, audio, format)
}
// compressAudioWithFFmpeg compresses audio using ffmpeg with stdin/stdout pipes.
// Converts WAV to MP3 format (64 kbps for voice).
func (p *Platform) compressAudioWithFFmpeg(ctx context.Context, audio []byte, format string) ([]byte, string, error) {
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, "", fmt.Errorf("ffmpeg not found: %w", err)
}
args := []string{
"-i", "pipe:0",
"-ar", "16000", // 16kHz sample rate for voice
"-ac", "1", // mono
"-b:a", "64k", // 64 kbps bitrate (voice quality)
"-f", "mp3",
"-y",
"pipe:1",
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.Stdin = bytes.NewReader(audio)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, "", fmt.Errorf("ffmpeg compression failed: %w (stderr: %s)", err, stderr.String())
}
return stdout.Bytes(), "mp3", nil
}
// uploadMedia uploads a file to DingTalk media API and returns the media ID.
// mediaType should be "voice" or "image".
func (p *Platform) uploadMedia(ctx context.Context, data []byte, fileName, mediaType string) (string, error) {
token, err := p.getAccessToken()
if err != nil {
return "", fmt.Errorf("get access token: %w", err)
}
uploadURL := fmt.Sprintf("https://oapi.dingtalk.com/media/upload?access_token=%s&type=%s", token, mediaType)
body := bytes.NewBuffer(nil)
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("media", fileName)
if err != nil {
return "", fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(data); err != nil {
return "", fmt.Errorf("write media data: %w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
if err != nil {
return "", fmt.Errorf("create upload request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("upload request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read upload response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("upload returned status %d: %s", resp.StatusCode, respBody)
}
slog.Debug("dingtalk: media upload response", "status", resp.StatusCode, "body", string(respBody))
var uploadResp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(respBody, &uploadResp); err != nil {
return "", fmt.Errorf("decode upload response: %w, body: %s", err, respBody)
}
if uploadResp.ErrCode != 0 {
return "", fmt.Errorf("upload API error %d: %s", uploadResp.ErrCode, uploadResp.ErrMsg)
}
if uploadResp.MediaID == "" {
return "", fmt.Errorf("empty media_id in upload response: %s", respBody)
}
slog.Debug("dingtalk: media uploaded successfully", "media_id", uploadResp.MediaID, "type", mediaType, "size", len(data))
return uploadResp.MediaID, nil
}
func (p *Platform) Stop() error {
if p.streamCtxCancel != nil {
p.streamCtxCancel()
}
if p.streamClient != nil {
p.streamClient.Close()
}
return nil
}
// formatReplyContent prepends quoted text to the message content when the user
// replies to / quotes a previous message. richText is parsed from the raw JSON
// "text" object which the SDK's BotCallbackDataTextModel silently drops.
func (p *Platform) formatReplyContent(richText *richTextContent, fallback string) string {
content := richText.Content
if content == "" {
content = fallback
}
if richText.RepliedMsg == nil {
return content
}
quotedText := p.extractQuotedMessageText(richText.RepliedMsg)
if quotedText == "" {
return content
}
return fmt.Sprintf("引用: \"%s\"\n\n%s", quotedText, content)
}
func (p *Platform) extractQuotedMessageText(msg *repliedMessage) string {
if msg == nil {
return ""
}
switch msg.MsgType {
case "text":
return p.extractQuotedTextMessageText(msg.Content)
case "interactiveCard":
return p.extractInteractiveCardQuotedText(msg.Content)
default:
slog.Debug("dingtalk: quoted message type not supported", "type", msg.MsgType)
return ""
}
}
func (p *Platform) extractQuotedTextMessageText(raw json.RawMessage) string {
var repliedContent repliedTextContent
if err := json.Unmarshal(raw, &repliedContent); err != nil {
slog.Debug("dingtalk: failed to parse replied message content", "error", err)
return ""
}
return repliedContent.Text
}
func (p *Platform) extractInteractiveCardQuotedText(raw json.RawMessage) string {
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
slog.Debug("dingtalk: failed to parse quoted interactiveCard content", "error", err)
return ""
}
text := p.extractInteractiveCardTextValue(payload, 0)
if text == "" {
slog.Debug("dingtalk: quoted interactiveCard content has no extractable text")
}
return normalizeQuotedMessageText(text)
}
func (p *Platform) extractInteractiveCardTextValue(value any, depth int) string {
if depth > 4 {
return ""
}
switch v := value.(type) {
case string:
decoded, ok := decodeJSONObjectOrArray(v)
if !ok {
return ""
}
return p.extractInteractiveCardTextValue(decoded, depth+1)
case map[string]any:
for _, key := range p.interactiveCardTemplateKeys() {
if text := p.extractInteractiveCardPath(v, depth, "cardData", "cardParamMap", key); text != "" {
return text
}
}
for _, key := range p.interactiveCardTemplateKeys() {
if text := p.extractInteractiveCardPath(v, depth, "cardParamMap", key); text != "" {
return text
}
}
for _, key := range p.interactiveCardTopLevelKeys() {
if text := p.extractInteractiveCardPath(v, depth, key); text != "" {
return text
}
}
case []any:
for _, item := range v {
if text := p.extractInteractiveCardTextValue(item, depth+1); text != "" {
return text
}
}
}
return ""
}
func (p *Platform) extractInteractiveCardPath(root map[string]any, depth int, path ...string) string {
var current any = root
for _, part := range path {
m, ok := mapFromJSONValue(current)
if !ok {
return ""
}
next, ok := m[part]
if !ok {
return ""
}
current = next
}
return p.extractInteractiveCardLeafText(current, depth+1)
}
func (p *Platform) extractInteractiveCardLeafText(value any, depth int) string {
if depth > 4 {
return ""
}
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case map[string]any, []any:
return p.extractInteractiveCardTextValue(value, depth+1)
default:
return ""
}
}
func (p *Platform) interactiveCardTemplateKeys() []string {
key := strings.TrimSpace(p.cardTemplateKey)
if key == "" {
key = "content"
}
if key == "content" {
return []string{"content"}
}
return []string{key, "content"}
}
func (p *Platform) interactiveCardTopLevelKeys() []string {
return []string{"content", "text", "markdown", "title"}
}
func mapFromJSONValue(value any) (map[string]any, bool) {
switch v := value.(type) {
case map[string]any:
return v, true
case string:
decoded, ok := decodeJSONObjectOrArray(v)
if !ok {
return nil, false
}
m, ok := decoded.(map[string]any)
return m, ok
default:
return nil, false
}
}
func decodeJSONObjectOrArray(s string) (any, bool) {
text := strings.TrimSpace(s)
if text == "" || (!strings.HasPrefix(text, "{") && !strings.HasPrefix(text, "[")) {
return nil, false
}
var decoded any
if err := json.Unmarshal([]byte(text), &decoded); err != nil {
return nil, false
}
return decoded, true
}
func normalizeQuotedMessageText(s string) string {
text := strings.TrimSpace(s)
if text == "" {
return ""
}
runes := []rune(text)
if len(runes) <= maxQuotedMessageRunes {
return text
}
return string(runes[:maxQuotedMessageRunes]) + "..."
}
// ReconstructReplyCtx implements core.ReplyContextReconstructor.
// Session key format: "dingtalk:{convType}:{conversationId}:{senderStaffId}" or "dingtalk:{convType}:{conversationId}"
// where convType is "g" (group) or "d" (direct/1:1).
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
if !strings.HasPrefix(sessionKey, "dingtalk:") {
return nil, fmt.Errorf("dingtalk: not a dingtalk session key: %q", sessionKey)
}
stripped := strings.TrimPrefix(sessionKey, "dingtalk:")
parts := strings.SplitN(stripped, ":", 3)
if len(parts) < 2 {
return nil, fmt.Errorf("dingtalk: invalid session key format: %q", sessionKey)
}
convType := parts[0]
if convType != "g" && convType != "d" {
return nil, fmt.Errorf("dingtalk: invalid conversation type %q in session key: %q", convType, sessionKey)
}
conversationId := parts[1]
if conversationId == "" {
return nil, fmt.Errorf("dingtalk: empty conversationId in session key: %q", sessionKey)
}
var senderStaffId string
if len(parts) > 2 {
senderStaffId = parts[2]
}
return replyContext{
conversationId: conversationId,
senderStaffId: senderStaffId,
isGroup: convType == "g",
proactive: true,
}, nil
}
// sendProactiveMessage sends a message using the DingTalk group/direct message API
// instead of the temporary sessionWebhook. This enables cc-connect send, cron,
// webhook, and other proactive messaging features.
func (p *Platform) sendProactiveMessage(ctx context.Context, rc replyContext, content string) error {
token, err := p.getAccessToken()
if err != nil {
return fmt.Errorf("dingtalk: get access token for proactive send: %w", err)
}
content = preprocessDingTalkMarkdown(content)
var apiURL string
var requestBody map[string]any
if rc.isGroup && rc.conversationId != "" {
// Group message via /v1.0/robot/groupMessages/send
apiURL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
msgParam, _ := json.Marshal(map[string]string{"text": content})
requestBody = map[string]any{
"robotCode": p.robotCode,
"openConversationId": rc.conversationId,
"msgKey": "sampleMarkdown",
"msgParam": string(msgParam),
}
} else if rc.senderStaffId != "" {
// Direct message via /v1.0/robot/oToMessages/batchSend
apiURL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
msgParam, _ := json.Marshal(map[string]string{"title": "reply", "text": content})
requestBody = map[string]any{
"robotCode": p.robotCode,
"userIds": []string{rc.senderStaffId},
"msgKey": "sampleMarkdown",
"msgParam": string(msgParam),
}
} else {
return fmt.Errorf("dingtalk: proactive send requires conversationId (group) or senderStaffId (direct)")
}
body, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("dingtalk: marshal proactive message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("dingtalk: create proactive request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("dingtalk: proactive send request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("dingtalk: proactive send failed: status=%d, body=%s", resp.StatusCode, string(respBody))
}
slog.Debug("dingtalk: proactive message sent", "api", apiURL, "status", resp.StatusCode)
return nil
}
// StartTyping adds a "thinking" emoji reaction to the user's message.
// Implements core.TypingIndicator.
func (p *Platform) StartTyping(ctx context.Context, replyCtx any) func() {
rc, ok := replyCtx.(replyContext)
if !ok || rc.msgId == "" || rc.conversationId == "" {
return func() {}
}
err := p.addEmotion(ctx, rc.msgId, rc.conversationId, "👌", "")
if err != nil {
slog.Warn("dingtalk: failed to add thinking reaction", "error", err)
}
return func() {
log.Println("回答完毕")
}
}
// AddDoneReaction adds a done emoji on top of the thinking emoji.
// Implements core.TypingIndicatorDone.
func (p *Platform) AddDoneReaction(replyCtx any) {
rc, ok := replyCtx.(replyContext)
if !ok || rc.msgId == "" || rc.conversationId == "" {
return
}
cctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Add done emoji
if err := p.addEmotion(cctx, rc.msgId, rc.conversationId, "✅", ""); err != nil {
slog.Debug("dingtalk: failed to add done reaction", "error", err)
}
}
// addEmotion adds an emoji reaction to a message via DingTalk's emotion API.
func (p *Platform) addEmotion(ctx context.Context, msgId, convId, emoji, _ string) error {
token, err := p.getAccessToken()
if err != nil {
return fmt.Errorf("dingtalk: addEmotion get token: %w", err)
}
body := map[string]any{
"robotCode": p.robotCode,
"openMsgId": msgId,
"openConversationId": convId,
"emotionType": 2,
"emotionName": emoji,
"textEmotion": map[string]string{
"emotionName": emoji,
"text": emoji,
"backgroundId": "im_bg_2",
},
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("dingtalk: addEmotion marshal: %w", err)
}
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost,
"https://api.dingtalk.com/v1.0/robot/emotion/reply",
bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("dingtalk: addEmotion create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("dingtalk: addEmotion do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("dingtalk: addEmotion failed: status=%d, body=%s", resp.StatusCode, string(respBody))
}
return nil
}
// preprocessDingTalkMarkdown adapts content for DingTalk's markdown renderer:
// - Leading spaces → non-breaking spaces (prevents markdown from stripping indentation)
// - Single \n between non-empty lines → trailing two-space forced line break
// - Code blocks are left untouched
func preprocessDingTalkMarkdown(s string) string {
lines := strings.Split(s, "\n")
inCodeBlock := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
}
if inCodeBlock {
continue
}
spaceCount := len(line) - len(strings.TrimLeft(line, " "))
if spaceCount > 0 {
lines[i] = strings.Repeat("\u00A0", spaceCount) + line[spaceCount:]
}
}
var sb strings.Builder
for i, line := range lines {
sb.WriteString(line)
if i < len(lines)-1 {
if line != "" && lines[i+1] != "" {
sb.WriteString(" \n")
} else {
sb.WriteString("\n")
}
}
}
return sb.String()
}