Files
Memoh/internal/channel/adapters/dingtalk/inbound.go
T
BBQ 9e9cd73dd9 feat(channel): add DingTalk channel adapter
- Add DingTalk channel adapter (`internal/channel/adapters/dingtalk/`) using dingtalk-stream-sdk-go, supporting inbound message receiving and outbound text/markdown reply
- Register DingTalk adapter in cmd/agent and cmd/memoh
- Add go.mod dependency: github.com/memohai/dingtalk-stream-sdk-go
- Add Dingtalk and Wecom SVG icons and Vue components to @memohai/icon
- Refactor existing icon components to remove redundant inline wrappers
- Add `channelTypeDisplayName` util for consistent channel label resolution
- Add DingTalk/WeCom i18n entries (en/zh) for types and typesShort
- Extend channel-icon, bot-channels, channel-settings-panel to support dingtalk/wecom
- Use channelTypeDisplayName in profile page to replace ad-hoc i18n lookup
2026-04-08 09:45:06 +08:00

373 lines
11 KiB
Go

package dingtalk
import (
"encoding/json"
"strings"
"sync"
"time"
"github.com/memohai/dingtalk-stream-sdk-go/chatbot"
"github.com/memohai/memoh/internal/channel"
)
// sessionWebhookContext holds a cached session webhook for a received message.
type sessionWebhookContext struct {
SessionWebhook string
ExpiredTime int64 // unix milliseconds; 0 means never set
ConversationID string
SenderID string
CreatedAt time.Time
}
// isValid reports whether the session webhook is still within its validity window.
func (w sessionWebhookContext) isValid() bool {
if strings.TrimSpace(w.SessionWebhook) == "" {
return false
}
if w.ExpiredTime <= 0 {
return false
}
return time.Now().UnixMilli() < w.ExpiredTime
}
// sessionWebhookCache stores recent sessionWebhook contexts keyed by msgId.
type sessionWebhookCache struct {
mu sync.RWMutex
items map[string]sessionWebhookContext
ttl time.Duration
}
func newSessionWebhookCache(ttl time.Duration) *sessionWebhookCache {
if ttl <= 0 {
ttl = 30 * time.Minute
}
return &sessionWebhookCache{
items: make(map[string]sessionWebhookContext),
ttl: ttl,
}
}
func (c *sessionWebhookCache) put(msgID string, ctx sessionWebhookContext) {
key := strings.TrimSpace(msgID)
if key == "" {
return
}
if ctx.CreatedAt.IsZero() {
ctx.CreatedAt = time.Now().UTC()
}
c.mu.Lock()
c.items[key] = ctx
c.gcLocked()
c.mu.Unlock()
}
func (c *sessionWebhookCache) get(msgID string) (sessionWebhookContext, bool) {
key := strings.TrimSpace(msgID)
if key == "" {
return sessionWebhookContext{}, false
}
c.mu.RLock()
item, ok := c.items[key]
c.mu.RUnlock()
if !ok {
return sessionWebhookContext{}, false
}
if time.Since(item.CreatedAt) > c.ttl {
c.mu.Lock()
delete(c.items, key)
c.mu.Unlock()
return sessionWebhookContext{}, false
}
return item, true
}
func (c *sessionWebhookCache) gcLocked() {
if len(c.items) < 512 {
return
}
now := time.Now().UTC()
for key, item := range c.items {
if now.Sub(item.CreatedAt) > c.ttl {
delete(c.items, key)
}
}
}
// richTextItem is an element within a DingTalk richText message payload.
type richTextItem struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
DownloadCode string `json:"downloadCode,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// pictureContent is the payload for msgtype="picture".
type pictureContent struct {
DownloadCode string `json:"downloadCode"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// fileContent is the payload for msgtype="file".
type fileContent struct {
DownloadCode string `json:"downloadCode"`
FileName string `json:"fileName,omitempty"`
FileSize string `json:"fileSize,omitempty"`
FileType string `json:"fileType,omitempty"`
}
// audioContent is the payload for msgtype="audio".
type audioContent struct {
DownloadCode string `json:"downloadCode,omitempty"`
Duration int `json:"duration,omitempty"`
Recognition string `json:"recognition,omitempty"`
}
// videoContent is the payload for msgtype="video".
type videoContent struct {
VideoMediaId string `json:"videoMediaId"`
Duration int `json:"duration,omitempty"`
VideoType string `json:"videoType,omitempty"`
}
// buildInboundMessage converts a DingTalk BotCallbackDataModel to a channel.InboundMessage.
// Returns false when the message should be silently ignored (e.g. empty content).
func buildInboundMessage(data *chatbot.BotCallbackDataModel) (channel.InboundMessage, bool) {
text, format, attachments := extractContent(data)
if strings.TrimSpace(text) == "" && len(attachments) == 0 {
return channel.InboundMessage{}, false
}
convType := normalizeDingTalkConversationType(data.ConversationType)
convID := strings.TrimSpace(data.ConversationId)
if convID == "" {
convID = strings.TrimSpace(data.SenderId)
}
// ReplyTarget: for private chat use user:{senderId}, for group chat use group:{conversationId}.
replyTarget := buildReplyTarget(convType, data.ConversationId, data.SenderId)
// Sanitize @ mentions from text body (DingTalk prepends "@botNick " in group messages).
if strings.TrimSpace(text) != "" {
text = strings.TrimSpace(text)
}
return channel.InboundMessage{
Channel: Type,
Message: channel.Message{
ID: strings.TrimSpace(data.MsgId),
Format: format,
Text: text,
Attachments: attachments,
},
ReplyTarget: replyTarget,
Sender: channel.Identity{
SubjectID: strings.TrimSpace(data.SenderId),
DisplayName: strings.TrimSpace(data.SenderNick),
Attributes: map[string]string{
"user_id": strings.TrimSpace(data.SenderId),
"staff_id": strings.TrimSpace(data.SenderStaffId),
"corp_id": strings.TrimSpace(data.SenderCorpId),
"chatbot_user_id": strings.TrimSpace(data.ChatbotUserId),
},
},
Conversation: channel.Conversation{
ID: convID,
Type: convType,
Name: strings.TrimSpace(data.ConversationTitle),
Metadata: map[string]any{
"open_conversation_id": strings.TrimSpace(data.ConversationId),
},
},
ReceivedAt: parseCreateAt(data.CreateAt),
Source: "dingtalk",
Metadata: map[string]any{
"msg_id": strings.TrimSpace(data.MsgId),
"conversation_id": strings.TrimSpace(data.ConversationId),
"conversation_type": strings.TrimSpace(data.ConversationType),
"chatbot_corp_id": strings.TrimSpace(data.ChatbotCorpId),
"is_admin": data.IsAdmin,
"session_webhook": strings.TrimSpace(data.SessionWebhook),
"session_webhook_exp": data.SessionWebhookExpiredTime,
},
}, true
}
// extractContent parses the msgtype-specific payload and returns text, format, and attachments.
func extractContent(data *chatbot.BotCallbackDataModel) (string, channel.MessageFormat, []channel.Attachment) {
msgtype := strings.ToLower(strings.TrimSpace(data.Msgtype))
switch msgtype {
case "text":
return strings.TrimSpace(data.Text.Content), channel.MessageFormatPlain, nil
case "markdown":
content := extractStringField(data.Content, "text")
if content == "" {
content = strings.TrimSpace(data.Text.Content)
}
return content, channel.MessageFormatMarkdown, nil
case "picture", "image":
raw, _ := json.Marshal(data.Content)
var pic pictureContent
if err := json.Unmarshal(raw, &pic); err != nil || strings.TrimSpace(pic.DownloadCode) == "" {
return "", channel.MessageFormatPlain, nil
}
att := channel.Attachment{
Type: channel.AttachmentImage,
PlatformKey: strings.TrimSpace(pic.DownloadCode),
SourcePlatform: Type.String(),
Width: pic.Width,
Height: pic.Height,
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
case "file":
raw, _ := json.Marshal(data.Content)
var f fileContent
if err := json.Unmarshal(raw, &f); err != nil || strings.TrimSpace(f.DownloadCode) == "" {
return "", channel.MessageFormatPlain, nil
}
att := channel.Attachment{
Type: channel.AttachmentFile,
PlatformKey: strings.TrimSpace(f.DownloadCode),
SourcePlatform: Type.String(),
Name: strings.TrimSpace(f.FileName),
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
case "audio", "voice":
raw, _ := json.Marshal(data.Content)
var a audioContent
_ = json.Unmarshal(raw, &a)
// Prefer voice-recognition text; fall back to attachment if code present.
if rec := strings.TrimSpace(a.Recognition); rec != "" {
return rec, channel.MessageFormatPlain, nil
}
if code := strings.TrimSpace(a.DownloadCode); code != "" {
att := channel.Attachment{
Type: channel.AttachmentVoice,
PlatformKey: code,
SourcePlatform: Type.String(),
DurationMs: int64(a.Duration) * 1000,
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
}
return "", channel.MessageFormatPlain, nil
case "video":
raw, _ := json.Marshal(data.Content)
var v videoContent
if err := json.Unmarshal(raw, &v); err != nil || strings.TrimSpace(v.VideoMediaId) == "" {
return "", channel.MessageFormatPlain, nil
}
att := channel.Attachment{
Type: channel.AttachmentVideo,
PlatformKey: strings.TrimSpace(v.VideoMediaId),
SourcePlatform: Type.String(),
DurationMs: int64(v.Duration) * 1000,
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
case "richtext":
return extractRichText(data.Content)
default:
// Try to extract a text field from Content as a best-effort fallback.
if text := extractStringField(data.Content, "content", "text"); text != "" {
return text, channel.MessageFormatPlain, nil
}
return "", channel.MessageFormatPlain, nil
}
}
// extractRichText parses a richText message content into text and attachments.
func extractRichText(raw any) (string, channel.MessageFormat, []channel.Attachment) {
rawJSON, _ := json.Marshal(raw)
var payload struct {
RichText []richTextItem `json:"richText"`
}
if err := json.Unmarshal(rawJSON, &payload); err != nil {
return "", channel.MessageFormatPlain, nil
}
var textParts []string
var attachments []channel.Attachment
for _, item := range payload.RichText {
switch strings.ToLower(strings.TrimSpace(item.Type)) {
case "text":
if v := strings.TrimSpace(item.Text); v != "" {
textParts = append(textParts, v)
}
case "picture", "image":
if code := strings.TrimSpace(item.DownloadCode); code != "" {
att := channel.Attachment{
Type: channel.AttachmentImage,
PlatformKey: code,
SourcePlatform: Type.String(),
Width: item.Width,
Height: item.Height,
}
attachments = append(attachments, channel.NormalizeInboundChannelAttachment(att))
}
}
}
return strings.Join(textParts, "\n"), channel.MessageFormatPlain, attachments
}
// extractStringField attempts to read a string value from an interface{} map by trying keys in order.
func extractStringField(raw any, keys ...string) string {
m, ok := raw.(map[string]any)
if !ok {
// Try JSON round-trip for non-map types.
b, err := json.Marshal(raw)
if err != nil {
return ""
}
if err := json.Unmarshal(b, &m); err != nil {
return ""
}
}
for _, key := range keys {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
// normalizeDingTalkConversationType maps DingTalk conversationType values to Memoh constants.
// "1" = single/private; "2" = group.
func normalizeDingTalkConversationType(raw string) string {
switch strings.TrimSpace(raw) {
case "2", "group":
return channel.ConversationTypeGroup
default:
return channel.ConversationTypePrivate
}
}
// buildReplyTarget produces the canonical reply target for a DingTalk inbound message.
func buildReplyTarget(convType, conversationID, senderID string) string {
if channel.IsPrivateConversationType(convType) {
if v := strings.TrimSpace(senderID); v != "" {
return "user:" + v
}
}
if v := strings.TrimSpace(conversationID); v != "" {
return "group:" + v
}
return ""
}
// parseCreateAt converts a DingTalk createAt unix millisecond timestamp to time.Time.
func parseCreateAt(ms int64) time.Time {
if ms <= 0 {
return time.Now().UTC()
}
return time.UnixMilli(ms).UTC()
}