Files
dobotai-platform/internal/channels/dingtalk/group_sender.go
T

365 lines
11 KiB
Go

package dingtalk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
const (
groupSendURL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
// maxGroupPayloadBytes caps the serialized JSON body sent to the group
// message endpoint.
maxGroupPayloadBytes = 64 << 10 // 64 KiB
// questionRuneLimit caps the displayed current question.
questionRuneLimit = 500
// previousQuestionRuneLimit caps each displayed historical question.
previousQuestionRuneLimit = 300
)
// SupportGroupSender sends DingTalk group Markdown notifications via the
// official groupMessages/send OpenAPI. It is safe by construction: all
// config fields are trimmed, nil client installs hardened defaults, and
// all error paths are sanitised.
type SupportGroupSender struct {
clientID string
clientSecret string
openConversation string
recordLinks *faqtable.RecordLinkBuilder
tokenURL string
groupURL string
apiClient *http.Client
mu sync.Mutex
token string
tokenTill time.Time
}
type SupportGroupSenderOption func(*SupportGroupSender)
func WithFAQRecordLinkBuilder(builder *faqtable.RecordLinkBuilder) SupportGroupSenderOption {
return func(sender *SupportGroupSender) { sender.recordLinks = builder }
}
var _ coverage.Notifier = (*SupportGroupSender)(nil)
// NewSupportGroupSender creates a SupportGroupSender. All three config values
// are required after trimming. A nil http.Client installs a hardened default
// (no redirects, Proxy=nil, 15s timeout).
func NewSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, oauthTokenURL, groupSendURL, false, opts...)
}
// newSupportGroupSenderForTest is used by tests to inject httptest URLs.
func newSupportGroupSenderForTest(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, tokenURL, groupURL, true, opts...)
}
func newSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, allowTest bool, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
openConversationID = strings.TrimSpace(openConversationID)
if clientID == "" {
return nil, errors.New("dingtalk group sender: clientID is required")
}
if clientSecret == "" {
return nil, errors.New("dingtalk group sender: clientSecret is required")
}
if openConversationID == "" {
return nil, errors.New("dingtalk group sender: openConversationId is required")
}
base := &http.Client{Timeout: 15 * time.Second}
if client != nil {
copyClient := *client
base = &copyClient
if base.Timeout <= 0 {
base.Timeout = 15 * time.Second
}
}
apiClient := *base
apiClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return errors.New("dingtalk API redirect refused")
}
if !allowTest {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
apiClient.Transport = transport
}
sender := &SupportGroupSender{
clientID: clientID,
clientSecret: clientSecret,
openConversation: openConversationID,
tokenURL: tokenURL,
groupURL: groupURL,
apiClient: &apiClient,
}
for _, opt := range opts {
if opt != nil {
opt(sender)
}
}
if allowTest && sender.recordLinks == nil {
sender.recordLinks, _ = faqtable.NewRecordLinkBuilder("test-base", "test-sheet", "test-view")
}
return sender, nil
}
// Send sends a support-case notification to the DingTalk group.
func (s *SupportGroupSender) Send(ctx context.Context, notif coverage.SupportNotification) error {
if notif.CaseID == "" {
return errors.New("dingtalk group sender: CaseID is required")
}
if s.recordLinks == nil {
return errors.New("dingtalk group sender: FAQ record links are required")
}
recordURL, err := s.recordLinks.Build(notif.FAQRecordID)
if err != nil {
return errors.New("dingtalk group sender: FAQ record link failed")
}
title, text := s.renderMarkdown(notif)
msgParam := map[string]string{
"title": title,
"text": text,
"singleTitle": "去回答",
"singleURL": recordURL,
}
msgParamJSON, err := json.Marshal(msgParam)
if err != nil {
return errors.New("dingtalk group sender: encode msgParam failed")
}
body := map[string]any{
"msgKey": "sampleActionCard",
"msgParam": string(msgParamJSON),
"openConversationId": s.openConversation,
"robotCode": s.clientID,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return errors.New("dingtalk group sender: encode request body failed")
}
if len(bodyJSON) > maxGroupPayloadBytes {
return fmt.Errorf("dingtalk group sender: payload exceeds %d bytes", maxGroupPayloadBytes)
}
// Get access token
token, err := s.accessToken(ctx)
if err != nil {
return err
}
// POST to groupMessages/send
respPayload, err := s.doJSON(ctx, http.MethodPost, s.groupURL, body, token)
if err != nil {
return err
}
// Check business response
var bizResp struct {
ProcessQueryKey string `json:"processQueryKey"`
ErrCode int `json:"errcode"`
}
if err := json.Unmarshal(respPayload, &bizResp); err != nil {
return errors.New("dingtalk group sender: invalid API response")
}
if bizResp.ErrCode != 0 {
return errors.New("dingtalk group sender: API returned business error")
}
if strings.TrimSpace(bizResp.ProcessQueryKey) == "" {
return errors.New("dingtalk group sender: incomplete API response")
}
return nil
}
// SendTest sends a fixed, non-sensitive message used to verify that the
// configured robot can reach the candidate support group. It deliberately
// does not require an FAQ-table binding.
func (s *SupportGroupSender) SendTest(ctx context.Context) error {
msgParam, err := json.Marshal(map[string]string{
"title": "DobotAI 技术支持群绑定测试",
"text": "DobotAI 技术支持群绑定测试成功。",
})
if err != nil {
return errors.New("dingtalk group sender: encode test message failed")
}
body := map[string]any{
"msgKey": "sampleMarkdown",
"msgParam": string(msgParam),
"openConversationId": s.openConversation,
"robotCode": s.clientID,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return errors.New("dingtalk group sender: encode test request failed")
}
if len(bodyJSON) > maxGroupPayloadBytes {
return fmt.Errorf("dingtalk group sender: payload exceeds %d bytes", maxGroupPayloadBytes)
}
token, err := s.accessToken(ctx)
if err != nil {
return err
}
respPayload, err := s.doJSON(ctx, http.MethodPost, s.groupURL, body, token)
if err != nil {
return err
}
var response struct {
ProcessQueryKey string `json:"processQueryKey"`
ErrCode int `json:"errcode"`
}
if json.Unmarshal(respPayload, &response) != nil {
return errors.New("dingtalk group sender: invalid API response")
}
if response.ErrCode != 0 {
return errors.New("dingtalk group sender: API returned business error")
}
if strings.TrimSpace(response.ProcessQueryKey) == "" {
return errors.New("dingtalk group sender: incomplete API response")
}
return nil
}
// renderMarkdown produces the compact customer-question context shown in the action card.
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
label := "知识库未覆盖提醒"
if notif.TriggerType == coverage.SupportTriggerFeedbackDown {
label = "回答被点踩提醒"
}
title = label
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
sender := escapeMarkdown(notif.SenderName)
askedAt := "未知"
if !notif.AskedAt.IsZero() {
askedAt = notif.AskedAt.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")
}
var b strings.Builder
b.WriteString("## 【" + label + "】\n\n")
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
b.WriteString(fmt.Sprintf("**语言:** %s\n\n", faqtable.DetectLanguage(notif.Question)))
b.WriteString("**最近5次历史问题:**\n")
if len(notif.PreviousQuestions) == 0 {
b.WriteString("暂无历史问题\n")
} else {
for i, previous := range notif.PreviousQuestions {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, escapeMarkdown(truncateRunes(previous, previousQuestionRuneLimit))))
}
}
return title, b.String()
}
func escapeMarkdown(value string) string {
value = strings.Join(strings.Fields(value), " ")
replacer := strings.NewReplacer(
"\\", "\\\\", "`", "\\`", "*", "\\*", "_", "\\_",
"[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)",
"#", "\\#", ">", "\\>",
)
return replacer.Replace(value)
}
func truncateRunes(s string, limit int) string {
if limit <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit]) + "..."
}
func (s *SupportGroupSender) accessToken(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.token != "" && time.Now().Before(s.tokenTill) {
return s.token, nil
}
payload, err := s.doJSON(ctx, http.MethodPost, s.tokenURL, map[string]string{
"appKey": s.clientID,
"appSecret": s.clientSecret,
}, "")
if err != nil {
return "", err
}
var response struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if json.Unmarshal(payload, &response) != nil || strings.TrimSpace(response.AccessToken) == "" {
return "", errors.New("dingtalk group sender: invalid OAuth response")
}
ttl := time.Duration(response.ExpireIn) * time.Second
refreshAfter := ttl - 5*time.Minute
if refreshAfter <= 0 {
refreshAfter = ttl / 2
}
if refreshAfter <= 0 {
refreshAfter = time.Minute
}
s.token = response.AccessToken
s.tokenTill = time.Now().Add(refreshAfter)
return s.token, nil
}
func (s *SupportGroupSender) doJSON(ctx context.Context, method, endpoint string, value any, token string) ([]byte, error) {
body, err := json.Marshal(value)
if err != nil {
return nil, errors.New("dingtalk group sender: encode API request failed")
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, errors.New("dingtalk group sender: create API request failed")
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("x-acs-dingtalk-access-token", token)
}
resp, err := s.apiClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Propagate redirect errors so callers can detect them.
if strings.Contains(err.Error(), "redirect") {
return nil, err
}
return nil, errors.New("dingtalk group sender: API request failed")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Discard body without reading it — sanitised error
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxAPIResponseBytes))
return nil, fmt.Errorf("dingtalk group sender: API HTTP %d", resp.StatusCode)
}
return readBoundedAPIResponse(resp)
}