初始化仓库
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user