Compare commits
3 Commits
4d1dd25472
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 38f01ecac7 | |||
| 9731bff268 | |||
| c01712ee6a |
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -31,7 +32,8 @@ type replyContext struct {
|
|||||||
conversationId string
|
conversationId string
|
||||||
senderStaffId string
|
senderStaffId string
|
||||||
isGroup bool
|
isGroup bool
|
||||||
proactive bool // true when constructed by ReconstructReplyCtx (no sessionWebhook)
|
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,
|
// richTextContent mirrors the full structure of the DingTalk "text" JSON field,
|
||||||
@@ -80,6 +82,9 @@ type Platform struct {
|
|||||||
cardThrottleMs int
|
cardThrottleMs int
|
||||||
degradeUntil time.Time
|
degradeUntil time.Time
|
||||||
degradeMu sync.Mutex
|
degradeMu sync.Mutex
|
||||||
|
|
||||||
|
// Admin tag configuration
|
||||||
|
adminUsers map[string]bool // user IDs that get "[管理员]" tag
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(opts map[string]any) (core.Platform, error) {
|
func New(opts map[string]any) (core.Platform, error) {
|
||||||
@@ -130,6 +135,18 @@ func New(opts map[string]any) (core.Platform, error) {
|
|||||||
cardThrottleMs = v
|
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{
|
return &Platform{
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
@@ -142,6 +159,7 @@ func New(opts map[string]any) (core.Platform, error) {
|
|||||||
cardTemplateID: cardTemplateID,
|
cardTemplateID: cardTemplateID,
|
||||||
cardTemplateKey: cardTemplateKey,
|
cardTemplateKey: cardTemplateKey,
|
||||||
cardThrottleMs: cardThrottleMs,
|
cardThrottleMs: cardThrottleMs,
|
||||||
|
adminUsers: adminUsers,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +197,16 @@ func (p *Platform) Start(handler core.MessageHandler) error {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.streamClient.Start(ctx); err != nil {
|
func() {
|
||||||
slog.Warn("dingtalk: stream disconnected, reconnecting", "error", err)
|
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.
|
// Brief pause before reconnecting to avoid tight loop on persistent failures.
|
||||||
select {
|
select {
|
||||||
@@ -264,6 +289,12 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 告诉ai该用户是否是管理员
|
||||||
|
userName := data.SenderNick
|
||||||
|
if p.adminUsers[data.SenderStaffId] {
|
||||||
|
userName += "[管理员]"
|
||||||
|
}
|
||||||
|
|
||||||
// Handle richText messages — extract plain text and images from rich content
|
// Handle richText messages — extract plain text and images from rich content
|
||||||
if data.Msgtype == "richText" {
|
if data.Msgtype == "richText" {
|
||||||
text, imageCodes := extractRichText(data.Content)
|
text, imageCodes := extractRichText(data.Content)
|
||||||
@@ -290,7 +321,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
|
|||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Platform: "dingtalk",
|
Platform: "dingtalk",
|
||||||
UserID: data.SenderStaffId,
|
UserID: data.SenderStaffId,
|
||||||
UserName: data.SenderNick,
|
UserName: userName,
|
||||||
ChatName: data.ConversationTitle,
|
ChatName: data.ConversationTitle,
|
||||||
Content: text,
|
Content: text,
|
||||||
MessageID: data.MsgId,
|
MessageID: data.MsgId,
|
||||||
@@ -299,6 +330,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
|
|||||||
sessionWebhook: data.SessionWebhook,
|
sessionWebhook: data.SessionWebhook,
|
||||||
conversationId: data.ConversationId,
|
conversationId: data.ConversationId,
|
||||||
senderStaffId: data.SenderStaffId,
|
senderStaffId: data.SenderStaffId,
|
||||||
|
msgId: data.MsgId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
p.handler(p, msg)
|
p.handler(p, msg)
|
||||||
@@ -325,7 +357,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
|
|||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Platform: "dingtalk",
|
Platform: "dingtalk",
|
||||||
UserID: data.SenderStaffId,
|
UserID: data.SenderStaffId,
|
||||||
UserName: data.SenderNick,
|
UserName: userName,
|
||||||
ChatName: data.ConversationTitle,
|
ChatName: data.ConversationTitle,
|
||||||
Content: messageContent,
|
Content: messageContent,
|
||||||
MessageID: data.MsgId,
|
MessageID: data.MsgId,
|
||||||
@@ -335,6 +367,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
|
|||||||
conversationId: data.ConversationId,
|
conversationId: data.ConversationId,
|
||||||
senderStaffId: data.SenderStaffId,
|
senderStaffId: data.SenderStaffId,
|
||||||
isGroup: data.ConversationType == "2",
|
isGroup: data.ConversationType == "2",
|
||||||
|
msgId: data.MsgId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,11 +434,15 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio
|
|||||||
slog.Error("dingtalk: failed to download audio", "error", err)
|
slog.Error("dingtalk: failed to download audio", "error", err)
|
||||||
// Fallback to recognition text if available
|
// Fallback to recognition text if available
|
||||||
if recognition != "" {
|
if recognition != "" {
|
||||||
|
userName := data.SenderNick
|
||||||
|
if p.adminUsers[data.SenderStaffId] {
|
||||||
|
userName += "[管理员]"
|
||||||
|
}
|
||||||
msg := &core.Message{
|
msg := &core.Message{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Platform: "dingtalk",
|
Platform: "dingtalk",
|
||||||
UserID: data.SenderStaffId,
|
UserID: data.SenderStaffId,
|
||||||
UserName: data.SenderNick,
|
UserName: userName,
|
||||||
Content: recognition,
|
Content: recognition,
|
||||||
MessageID: data.MsgId,
|
MessageID: data.MsgId,
|
||||||
ChannelKey: data.ConversationId,
|
ChannelKey: data.ConversationId,
|
||||||
@@ -414,6 +451,7 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio
|
|||||||
conversationId: data.ConversationId,
|
conversationId: data.ConversationId,
|
||||||
senderStaffId: data.SenderStaffId,
|
senderStaffId: data.SenderStaffId,
|
||||||
isGroup: data.ConversationType == "2",
|
isGroup: data.ConversationType == "2",
|
||||||
|
msgId: data.MsgId,
|
||||||
},
|
},
|
||||||
FromVoice: true,
|
FromVoice: true,
|
||||||
}
|
}
|
||||||
@@ -438,6 +476,7 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio
|
|||||||
conversationId: data.ConversationId,
|
conversationId: data.ConversationId,
|
||||||
senderStaffId: data.SenderStaffId,
|
senderStaffId: data.SenderStaffId,
|
||||||
isGroup: data.ConversationType == "2",
|
isGroup: data.ConversationType == "2",
|
||||||
|
msgId: data.MsgId,
|
||||||
},
|
},
|
||||||
FromVoice: true,
|
FromVoice: true,
|
||||||
Audio: &core.AudioAttachment{
|
Audio: &core.AudioAttachment{
|
||||||
@@ -513,6 +552,7 @@ func (p *Platform) handleImageMessage(data *chatbot.BotCallbackDataModel, sessio
|
|||||||
sessionWebhook: data.SessionWebhook,
|
sessionWebhook: data.SessionWebhook,
|
||||||
conversationId: data.ConversationId,
|
conversationId: data.ConversationId,
|
||||||
senderStaffId: data.SenderStaffId,
|
senderStaffId: data.SenderStaffId,
|
||||||
|
msgId: data.MsgId,
|
||||||
},
|
},
|
||||||
Images: []core.ImageAttachment{{
|
Images: []core.ImageAttachment{{
|
||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
@@ -832,6 +872,8 @@ func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttach
|
|||||||
var _ core.ImageSender = (*Platform)(nil)
|
var _ core.ImageSender = (*Platform)(nil)
|
||||||
var _ core.StreamingCardPlatform = (*Platform)(nil)
|
var _ core.StreamingCardPlatform = (*Platform)(nil)
|
||||||
var _ core.ReplyContextReconstructor = (*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.
|
// CreateStreamingCard creates a new streaming card for the given reply context.
|
||||||
// Implements core.StreamingCardPlatform.
|
// Implements core.StreamingCardPlatform.
|
||||||
@@ -1456,6 +1498,92 @@ func (p *Platform) sendProactiveMessage(ctx context.Context, rc replyContext, co
|
|||||||
return nil
|
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:
|
// preprocessDingTalkMarkdown adapts content for DingTalk's markdown renderer:
|
||||||
// - Leading spaces → non-breaking spaces (prevents markdown from stripping indentation)
|
// - Leading spaces → non-breaking spaces (prevents markdown from stripping indentation)
|
||||||
// - Single \n between non-empty lines → trailing two-space forced line break
|
// - Single \n between non-empty lines → trailing two-space forced line break
|
||||||
|
|||||||
Reference in New Issue
Block a user