365 lines
11 KiB
Go
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 = ©Client
|
|
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)
|
|
}
|