Files
2026-06-02 23:14:41 +08:00

458 lines
12 KiB
Go

package dingtalk
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
)
// aiCard implements core.StreamingCard for DingTalk AI Card streaming.
type aiCard struct {
cardInstanceId string
outTrackId string
templateKey string // 卡片模板变量名,默认 "content"
platform *Platform
mu sync.Mutex
state string // "processing" | "finished" | "failed"
lastSentContent string
lastSentAt time.Time
// 节流控制(single-flight + latest-wins 语义)
throttleMs int
pendingContent string
timer *time.Timer
inFlight bool
done chan struct{} // closed when finalized or failed
}
// Ensure aiCard implements core.StreamingCard
var _ core.StreamingCard = (*aiCard)(nil)
// generateOutTrackID generates a unique outTrackId for AI Card.
func generateOutTrackID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return fmt.Sprintf("card_%d_%s", time.Now().UnixMilli(), hex.EncodeToString(b))
}
// generateGUID generates a UUID-like string for API requests.
func generateGUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
// Set version (4) and variant bits
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// createAICard creates a new AI Card instance and delivers it to the conversation.
func (p *Platform) createAICard(ctx context.Context, rc replyContext) (*aiCard, error) {
token, err := p.getAccessToken()
if err != nil {
return nil, fmt.Errorf("get access token: %w", err)
}
outTrackId := generateOutTrackID()
isGroup := rc.isGroup
// Build openSpaceId based on conversation type
// See: https://open.dingtalk.com/document/development/create-and-deliver-cards
var openSpaceId string
if isGroup {
openSpaceId = fmt.Sprintf("dtv1.card//IM_GROUP.%s", rc.conversationId)
} else {
openSpaceId = fmt.Sprintf("dtv1.card//IM_ROBOT.%s", rc.senderStaffId)
}
// Build card data
cardParamMap := map[string]string{
"config": `{"autoLayout":true,"enableForward":true}`,
p.cardTemplateKey: "",
}
payload := map[string]any{
"cardTemplateId": p.cardTemplateID,
"outTrackId": outTrackId,
"cardData": map[string]any{
"cardParamMap": cardParamMap,
},
"callbackType": "STREAM",
"imGroupOpenSpaceModel": map[string]any{"supportForward": true},
"imRobotOpenSpaceModel": map[string]any{"supportForward": true},
"openSpaceId": openSpaceId,
"userIdType": 1,
}
// Set delivery model based on conversation type
if isGroup {
payload["imGroupOpenDeliverModel"] = map[string]any{
"robotCode": p.robotCode,
}
} else {
payload["imRobotOpenDeliverModel"] = map[string]any{
"spaceType": "IM_ROBOT",
"robotCode": p.robotCode,
}
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost,
"https://api.dingtalk.com/v1.0/card/instances/createAndDeliver",
bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
slog.Debug("dingtalk: creating AI card", "outTrackId", outTrackId, "isGroup", isGroup)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("dingtalk: createAndDeliver response",
"status", resp.StatusCode,
"body", string(respBody))
if resp.StatusCode != http.StatusOK {
slog.Error("dingtalk: create AI card failed",
"status", resp.StatusCode,
"body", string(respBody))
// Check if we should trigger degrade
if resp.StatusCode == 403 || resp.StatusCode == 429 || resp.StatusCode >= 500 {
p.activateCardDegrade(fmt.Sprintf("card.create:%d", resp.StatusCode))
}
return nil, fmt.Errorf("create AI card: status=%d, body=%s", resp.StatusCode, string(respBody))
}
// Parse response to get cardInstanceId
var result struct {
Result struct {
CardInstanceId string `json:"cardInstanceId"`
OutTrackId string `json:"outTrackId"`
ProcessQueryKey string `json:"processQueryKey"`
} `json:"result"`
CardInstanceId string `json:"cardInstanceId"`
OutTrackId string `json:"outTrackId"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
slog.Warn("dingtalk: failed to parse createAndDeliver response", "error", err, "body", string(respBody))
}
cardInstanceId := result.Result.CardInstanceId
if cardInstanceId == "" {
cardInstanceId = result.CardInstanceId
}
if cardInstanceId == "" {
cardInstanceId = outTrackId
}
resolvedOutTrackId := result.Result.OutTrackId
if resolvedOutTrackId == "" {
resolvedOutTrackId = result.OutTrackId
}
if resolvedOutTrackId == "" {
resolvedOutTrackId = outTrackId
}
// Check deliverResults for actual delivery success
var deliverCheck struct {
Result struct {
DeliverResults []struct {
Success bool `json:"success"`
ErrorMsg string `json:"errorMsg"`
} `json:"deliverResults"`
} `json:"result"`
}
if err := json.Unmarshal(respBody, &deliverCheck); err == nil {
for _, dr := range deliverCheck.Result.DeliverResults {
if !dr.Success {
slog.Warn("dingtalk: AI card delivery failed",
"errorMsg", dr.ErrorMsg,
"outTrackId", outTrackId,
"isGroup", isGroup)
return nil, fmt.Errorf("AI card delivery failed: %s", dr.ErrorMsg)
}
}
}
slog.Info("dingtalk: AI card created",
"cardInstanceId", cardInstanceId,
"outTrackId", resolvedOutTrackId)
card := &aiCard{
cardInstanceId: cardInstanceId,
outTrackId: resolvedOutTrackId,
templateKey: p.cardTemplateKey,
platform: p,
state: "processing",
throttleMs: p.cardThrottleMs,
done: make(chan struct{}),
}
return card, nil
}
// Update replaces the card content with the given markdown.
// Implements throttling using single-flight + latest-wins semantics.
func (c *aiCard) Update(ctx context.Context, content string) error {
c.mu.Lock()
// If already finished or failed, skip
if c.state == "finished" || c.state == "failed" {
c.mu.Unlock()
return nil
}
c.pendingContent = content
// If there's an in-flight request, schedule a timer
if c.inFlight {
c.scheduleFlushLocked()
c.mu.Unlock()
return nil
}
// If enough time has passed since last send, flush immediately
if c.timer == nil && time.Since(c.lastSentAt) >= time.Duration(c.throttleMs)*time.Millisecond {
c.mu.Unlock()
c.flush(ctx)
return nil
}
// Otherwise, schedule a timer
c.scheduleFlushLocked()
c.mu.Unlock()
return nil
}
// scheduleFlushLocked schedules a flush after throttleMs. Must be called with mu held.
func (c *aiCard) scheduleFlushLocked() {
if c.timer != nil {
return
}
delay := time.Duration(c.throttleMs)*time.Millisecond - time.Since(c.lastSentAt)
if delay < 0 {
delay = 0
}
c.timer = time.AfterFunc(delay, func() {
c.mu.Lock()
c.timer = nil
c.mu.Unlock()
c.flush(context.Background())
})
}
// flush sends the pending content to the DingTalk streaming API.
func (c *aiCard) flush(ctx context.Context) {
c.mu.Lock()
if c.state == "finished" || c.state == "failed" {
c.mu.Unlock()
return
}
if c.pendingContent == "" {
c.mu.Unlock()
return
}
if c.inFlight {
c.mu.Unlock()
return
}
content := c.pendingContent
c.pendingContent = ""
c.inFlight = true
c.mu.Unlock()
err := c.doStream(ctx, content, false)
c.mu.Lock()
c.inFlight = false
if err != nil {
slog.Error("dingtalk: AI card stream update failed", "error", err)
// Check pending content that arrived during in-flight
if c.pendingContent != "" {
c.scheduleFlushLocked()
}
} else {
c.lastSentContent = content
c.lastSentAt = time.Now()
// Check if new content arrived during in-flight
if c.pendingContent != "" {
c.scheduleFlushLocked()
}
}
c.mu.Unlock()
}
// doStream sends content to the DingTalk streaming API.
func (c *aiCard) doStream(ctx context.Context, content string, isFinalize bool) error {
token, err := c.platform.getAccessToken()
if err != nil {
return fmt.Errorf("get access token: %w", err)
}
payload := map[string]any{
"outTrackId": c.outTrackId,
"key": c.templateKey,
"content": content,
"isFull": true,
"isFinalize": isFinalize,
"isError": false,
"guid": generateGUID(),
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPut,
"https://api.dingtalk.com/v1.0/card/streaming",
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)
slog.Debug("dingtalk: streaming AI card",
"outTrackId", c.outTrackId,
"contentLen", len(content),
"isFinalize", isFinalize)
resp, err := c.platform.httpClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("dingtalk: streaming response",
"status", resp.StatusCode,
"body", string(respBody),
"isFinalize", isFinalize)
if resp.StatusCode != http.StatusOK {
slog.Error("dingtalk: stream AI card failed",
"status", resp.StatusCode,
"body", string(respBody))
// Check if we should trigger degrade
if resp.StatusCode == 403 || resp.StatusCode == 429 || resp.StatusCode >= 500 {
c.platform.activateCardDegrade(fmt.Sprintf("card.stream:%d", resp.StatusCode))
c.mu.Lock()
c.state = "failed"
close(c.done)
c.mu.Unlock()
}
return fmt.Errorf("stream AI card: status=%d, body=%s", resp.StatusCode, string(respBody))
}
slog.Debug("dingtalk: AI card streamed successfully", "isFinalize", isFinalize)
return nil
}
// Finalize sends the final content and marks the card as complete.
func (c *aiCard) Finalize(ctx context.Context, content string) error {
c.mu.Lock()
// Stop any pending timer
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
// If already finished or failed, skip
if c.state == "finished" || c.state == "failed" {
c.mu.Unlock()
return nil
}
// Wait for in-flight to complete
for c.inFlight {
c.mu.Unlock()
select {
case <-c.done:
return nil
case <-time.After(100 * time.Millisecond):
}
c.mu.Lock()
}
c.inFlight = true
c.mu.Unlock()
err := c.doStream(ctx, content, true)
c.mu.Lock()
c.inFlight = false
if err != nil {
c.state = "failed"
} else {
c.state = "finished"
c.lastSentContent = content
c.lastSentAt = time.Now()
}
select {
case <-c.done:
default:
close(c.done)
}
c.mu.Unlock()
return err
}
// Failed returns true if the card has entered a failed state.
func (c *aiCard) Failed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.state == "failed"
}
// isCardDegraded returns true if card API is temporarily degraded.
func (p *Platform) isCardDegraded() bool {
p.degradeMu.Lock()
defer p.degradeMu.Unlock()
return time.Now().Before(p.degradeUntil)
}
// activateCardDegrade activates card API degradation for 30 minutes.
func (p *Platform) activateCardDegrade(reason string) {
p.degradeMu.Lock()
defer p.degradeMu.Unlock()
p.degradeUntil = time.Now().Add(30 * time.Minute)
slog.Warn("dingtalk: AI card API degraded",
"reason", reason,
"until", p.degradeUntil.Format(time.RFC3339))
}