初始化仓库
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))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,858 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Thread safety tests for token caching
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetAccessToken_ConcurrentAccess(t *testing.T) {
|
||||
// This test verifies that concurrent calls to getAccessToken
|
||||
// with a pre-cached token are properly synchronized by the mutex
|
||||
|
||||
p := &Platform{
|
||||
clientID: "test_client",
|
||||
clientSecret: "test_secret",
|
||||
httpClient: &http.Client{}, // Valid HTTP client
|
||||
accessToken: "test_token", // Pre-cache a token
|
||||
tokenExpiry: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
|
||||
// Launch multiple goroutines to stress-test the mutex
|
||||
const numGoroutines = 100
|
||||
var wg sync.WaitGroup
|
||||
successCount := 0
|
||||
var countMu sync.Mutex
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
token, err := p.getAccessToken()
|
||||
if err == nil && token == "test_token" {
|
||||
countMu.Lock()
|
||||
successCount++
|
||||
countMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// All goroutines should have gotten the cached token
|
||||
if successCount != numGoroutines {
|
||||
t.Errorf("expected %d successful token retrievals, got %d", numGoroutines, successCount)
|
||||
}
|
||||
|
||||
t.Logf("Completed %d concurrent token requests without deadlock", numGoroutines)
|
||||
}
|
||||
|
||||
func TestGetAccessToken_MutexExists(t *testing.T) {
|
||||
// Verify that the tokenMu mutex field exists and works
|
||||
p := &Platform{
|
||||
clientID: "test_client",
|
||||
clientSecret: "test_secret",
|
||||
}
|
||||
|
||||
// Test that we can lock/unlock the mutex (verify no panic under lock)
|
||||
p.tokenMu.Lock()
|
||||
_ = p.clientID // SA2001: intentional empty section to verify Lock/Unlock work
|
||||
p.tokenMu.Unlock()
|
||||
|
||||
// Test with defer
|
||||
p.tokenMu.Lock()
|
||||
defer p.tokenMu.Unlock()
|
||||
|
||||
t.Log("tokenMu mutex is functional")
|
||||
}
|
||||
|
||||
func TestGetAccessToken_CachedTokenAccess(t *testing.T) {
|
||||
// Test that cached token access is thread-safe
|
||||
p := &Platform{
|
||||
clientID: "test_client",
|
||||
clientSecret: "test_secret",
|
||||
accessToken: "cached_token",
|
||||
tokenExpiry: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
|
||||
const numGoroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
tokens := make([]string, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
token, err := p.getAccessToken()
|
||||
if err == nil {
|
||||
tokens[idx] = token
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify all goroutines got the same cached token
|
||||
for i, token := range tokens {
|
||||
if token != "" && token != "cached_token" {
|
||||
t.Errorf("goroutine %d: expected cached token 'cached_token', got %q", i, token)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("All %d goroutines safely accessed cached token", numGoroutines)
|
||||
}
|
||||
|
||||
func TestPlatform_MutexFieldExists(t *testing.T) {
|
||||
// Verify the Platform struct has the tokenMu field
|
||||
p := &Platform{}
|
||||
|
||||
// Verify no panic under lock (test will fail to compile if tokenMu doesn't exist)
|
||||
p.tokenMu.Lock()
|
||||
_ = p.clientID // SA2001: intentional empty section to verify Lock/Unlock work
|
||||
p.tokenMu.Unlock()
|
||||
|
||||
t.Log("Platform.tokenMu field exists")
|
||||
}
|
||||
|
||||
func TestPlatform_AccessTokenFieldsExist(t *testing.T) {
|
||||
// Verify the Platform struct has the token caching fields
|
||||
p := &Platform{}
|
||||
|
||||
// Set the fields
|
||||
p.accessToken = "test_token"
|
||||
p.tokenExpiry = time.Now().Add(1 * time.Hour)
|
||||
|
||||
// Verify they're set
|
||||
if p.accessToken != "test_token" {
|
||||
t.Errorf("expected accessToken 'test_token', got %q", p.accessToken)
|
||||
}
|
||||
|
||||
t.Log("Platform token caching fields exist and are accessible")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// ReconstructReplyCtx tests
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestReconstructReplyCtx_GroupSharedSession(t *testing.T) {
|
||||
p := &Platform{}
|
||||
rctx, err := p.ReconstructReplyCtx("dingtalk:g:conv123")
|
||||
if err != nil {
|
||||
t.Fatalf("ReconstructReplyCtx() error = %v", err)
|
||||
}
|
||||
rc := rctx.(replyContext)
|
||||
if rc.conversationId != "conv123" {
|
||||
t.Errorf("conversationId = %q, want %q", rc.conversationId, "conv123")
|
||||
}
|
||||
if rc.senderStaffId != "" {
|
||||
t.Errorf("senderStaffId = %q, want empty", rc.senderStaffId)
|
||||
}
|
||||
if !rc.isGroup {
|
||||
t.Error("isGroup = false, want true for group session")
|
||||
}
|
||||
if !rc.proactive {
|
||||
t.Error("proactive = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructReplyCtx_GroupPerUserSession(t *testing.T) {
|
||||
p := &Platform{}
|
||||
rctx, err := p.ReconstructReplyCtx("dingtalk:g:conv123:user456")
|
||||
if err != nil {
|
||||
t.Fatalf("ReconstructReplyCtx() error = %v", err)
|
||||
}
|
||||
rc := rctx.(replyContext)
|
||||
if rc.conversationId != "conv123" {
|
||||
t.Errorf("conversationId = %q, want %q", rc.conversationId, "conv123")
|
||||
}
|
||||
if rc.senderStaffId != "user456" {
|
||||
t.Errorf("senderStaffId = %q, want %q", rc.senderStaffId, "user456")
|
||||
}
|
||||
if !rc.isGroup {
|
||||
t.Error("isGroup = false, want true for group session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructReplyCtx_DirectSession(t *testing.T) {
|
||||
p := &Platform{}
|
||||
rctx, err := p.ReconstructReplyCtx("dingtalk:d:conv789:user111")
|
||||
if err != nil {
|
||||
t.Fatalf("ReconstructReplyCtx() error = %v", err)
|
||||
}
|
||||
rc := rctx.(replyContext)
|
||||
if rc.conversationId != "conv789" {
|
||||
t.Errorf("conversationId = %q, want %q", rc.conversationId, "conv789")
|
||||
}
|
||||
if rc.senderStaffId != "user111" {
|
||||
t.Errorf("senderStaffId = %q, want %q", rc.senderStaffId, "user111")
|
||||
}
|
||||
if rc.isGroup {
|
||||
t.Error("isGroup = true, want false for direct session")
|
||||
}
|
||||
if !rc.proactive {
|
||||
t.Error("proactive = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructReplyCtx_InvalidPrefix(t *testing.T) {
|
||||
p := &Platform{}
|
||||
_, err := p.ReconstructReplyCtx("telegram:g:conv123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-dingtalk prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructReplyCtx_InvalidConvType(t *testing.T) {
|
||||
p := &Platform{}
|
||||
_, err := p.ReconstructReplyCtx("dingtalk:x:conv123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid conversation type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructReplyCtx_EmptyConversationId(t *testing.T) {
|
||||
p := &Platform{}
|
||||
_, err := p.ReconstructReplyCtx("dingtalk:g:")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty conversationId")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructReplyCtx_TooFewParts(t *testing.T) {
|
||||
p := &Platform{}
|
||||
_, err := p.ReconstructReplyCtx("dingtalk:")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too few parts")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// formatReplyContent tests
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFormatReplyContent_WithQuotedText(t *testing.T) {
|
||||
p := &Platform{}
|
||||
repliedContent, _ := json.Marshal(repliedTextContent{Text: "original message"})
|
||||
richText := &richTextContent{
|
||||
Content: "user reply",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "text",
|
||||
Content: repliedContent,
|
||||
},
|
||||
}
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \"original message\"\n\nuser reply"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_EmptyContent_UsesFallback(t *testing.T) {
|
||||
p := &Platform{}
|
||||
repliedContent, _ := json.Marshal(repliedTextContent{Text: "quoted"})
|
||||
richText := &richTextContent{
|
||||
Content: "",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "text",
|
||||
Content: repliedContent,
|
||||
},
|
||||
}
|
||||
result := p.formatReplyContent(richText, "fallback text")
|
||||
expected := "引用: \"quoted\"\n\nfallback text"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_TextQuotePreservesWhitespace(t *testing.T) {
|
||||
p := &Platform{}
|
||||
repliedContent, _ := json.Marshal(repliedTextContent{Text: " original message "})
|
||||
richText := &richTextContent{
|
||||
Content: "user reply",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "text",
|
||||
Content: repliedContent,
|
||||
},
|
||||
}
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \" original message \"\n\nuser reply"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_NilRepliedMsg(t *testing.T) {
|
||||
p := &Platform{}
|
||||
richText := &richTextContent{
|
||||
Content: "just a message",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: nil,
|
||||
}
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
if result != "just a message" {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, "just a message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_NonTextMsgType(t *testing.T) {
|
||||
p := &Platform{}
|
||||
richText := &richTextContent{
|
||||
Content: "user reply",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "image",
|
||||
Content: json.RawMessage(`{}`),
|
||||
},
|
||||
}
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
if result != "user reply" {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, "user reply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_WithQuotedInteractiveCardContent(t *testing.T) {
|
||||
p := &Platform{cardTemplateKey: "content"}
|
||||
richText := &richTextContent{
|
||||
Content: "user reply",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "interactiveCard",
|
||||
Content: json.RawMessage(`{
|
||||
"cardData": {
|
||||
"cardParamMap": {
|
||||
"config": "{\"autoLayout\":true}",
|
||||
"content": "bot card answer"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \"bot card answer\"\n\nuser reply"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_WithQuotedInteractiveCardCustomTemplateKey(t *testing.T) {
|
||||
p := &Platform{cardTemplateKey: "body"}
|
||||
richText := &richTextContent{
|
||||
Content: "next question",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "interactiveCard",
|
||||
Content: json.RawMessage(`{
|
||||
"cardData": {
|
||||
"cardParamMap": {
|
||||
"content": "default content",
|
||||
"body": "custom body content"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \"custom body content\"\n\nnext question"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_WithQuotedInteractiveCardNestedJSONEnvelope(t *testing.T) {
|
||||
p := &Platform{cardTemplateKey: "content"}
|
||||
richText := &richTextContent{
|
||||
Content: "continue",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "interactiveCard",
|
||||
Content: json.RawMessage(`{
|
||||
"cardData": "{\"cardParamMap\":{\"content\":\"nested card answer\"}}"
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \"nested card answer\"\n\ncontinue"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_WithQuotedInteractiveCardTopLevelFallback(t *testing.T) {
|
||||
p := &Platform{}
|
||||
richText := &richTextContent{
|
||||
Content: "what next?",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "interactiveCard",
|
||||
Content: json.RawMessage(`{
|
||||
"title": "Run Summary",
|
||||
"markdown": "all checks passed"
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \"all checks passed\"\n\nwhat next?"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_InteractiveCardPreservesVisibleJSONContent(t *testing.T) {
|
||||
p := &Platform{cardTemplateKey: "content"}
|
||||
richText := &richTextContent{
|
||||
Content: "follow up",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "interactiveCard",
|
||||
Content: json.RawMessage(`{
|
||||
"cardData": {
|
||||
"cardParamMap": {
|
||||
"config": "{\"autoLayout\":true}",
|
||||
"content": "{\"status\":\"ok\"}"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \"{\"status\":\"ok\"}\"\n\nfollow up"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_InteractiveCardTopLevelFallbackIgnoresCustomKey(t *testing.T) {
|
||||
p := &Platform{cardTemplateKey: "body"}
|
||||
richText := &richTextContent{
|
||||
Content: "follow up",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "interactiveCard",
|
||||
Content: json.RawMessage(`{
|
||||
"body": "custom top-level body",
|
||||
"content": "top-level content"
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expected := "引用: \"top-level content\"\n\nfollow up"
|
||||
if result != expected {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_TruncatesLongQuotedInteractiveCardContent(t *testing.T) {
|
||||
p := &Platform{cardTemplateKey: "content"}
|
||||
longText := strings.Repeat("x", maxQuotedMessageRunes+1)
|
||||
cardContent, err := json.Marshal(map[string]any{
|
||||
"cardData": map[string]any{
|
||||
"cardParamMap": map[string]string{
|
||||
"content": longText,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal card content: %v", err)
|
||||
}
|
||||
richText := &richTextContent{
|
||||
Content: "short reply",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "interactiveCard",
|
||||
Content: cardContent,
|
||||
},
|
||||
}
|
||||
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
expectedPrefix := "引用: \"" + strings.Repeat("x", maxQuotedMessageRunes) + "...\"\n\nshort reply"
|
||||
if result != expectedPrefix {
|
||||
t.Errorf("formatReplyContent() length = %d, want truncated output length %d", len([]rune(result)), len([]rune(expectedPrefix)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnRawMessage_QuotedInteractiveCardEnrichesMessageContent(t *testing.T) {
|
||||
var got *core.Message
|
||||
p := &Platform{
|
||||
cardTemplateKey: "content",
|
||||
handler: func(_ core.Platform, msg *core.Message) {
|
||||
got = msg
|
||||
},
|
||||
}
|
||||
|
||||
p.onRawMessage(`{
|
||||
"msgtype": "text",
|
||||
"msgId": "msg-1",
|
||||
"conversationType": "2",
|
||||
"conversationId": "conv-1",
|
||||
"conversationTitle": "team chat",
|
||||
"senderStaffId": "user-1",
|
||||
"senderNick": "Alice",
|
||||
"sessionWebhook": "https://example.invalid/webhook",
|
||||
"text": {
|
||||
"content": "please continue",
|
||||
"isReplyMsg": true,
|
||||
"repliedMsg": {
|
||||
"msgType": "interactiveCard",
|
||||
"content": {
|
||||
"cardData": {
|
||||
"cardParamMap": {
|
||||
"content": "previous card answer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("handler was not called")
|
||||
}
|
||||
expected := "引用: \"previous card answer\"\n\nplease continue"
|
||||
if got.Content != expected {
|
||||
t.Errorf("message content = %q, want %q", got.Content, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReplyContent_EmptyQuotedText(t *testing.T) {
|
||||
p := &Platform{}
|
||||
repliedContent, _ := json.Marshal(repliedTextContent{Text: ""})
|
||||
richText := &richTextContent{
|
||||
Content: "user reply",
|
||||
IsReplyMsg: true,
|
||||
RepliedMsg: &repliedMessage{
|
||||
MsgType: "text",
|
||||
Content: repliedContent,
|
||||
},
|
||||
}
|
||||
result := p.formatReplyContent(richText, "fallback")
|
||||
if result != "user reply" {
|
||||
t.Errorf("formatReplyContent() = %q, want %q", result, "user reply")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Proactive routing tests
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestProactiveRouting_GroupSessionUsesGroupAPI(t *testing.T) {
|
||||
// Verify that a group session key produces a replyContext with isGroup=true,
|
||||
// which sendProactiveMessage would route to groupMessages/send.
|
||||
p := &Platform{}
|
||||
rctx, err := p.ReconstructReplyCtx("dingtalk:g:conv123:user456")
|
||||
if err != nil {
|
||||
t.Fatalf("ReconstructReplyCtx() error = %v", err)
|
||||
}
|
||||
rc := rctx.(replyContext)
|
||||
if !rc.isGroup || rc.conversationId == "" {
|
||||
t.Errorf("group routing: isGroup=%v, conversationId=%q; want isGroup=true with non-empty conversationId", rc.isGroup, rc.conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProactiveRouting_DirectSessionUsesDirectAPI(t *testing.T) {
|
||||
// Verify that a direct session key produces a replyContext with isGroup=false,
|
||||
// which sendProactiveMessage would route to oToMessages/batchSend.
|
||||
p := &Platform{}
|
||||
rctx, err := p.ReconstructReplyCtx("dingtalk:d:conv789:user111")
|
||||
if err != nil {
|
||||
t.Fatalf("ReconstructReplyCtx() error = %v", err)
|
||||
}
|
||||
rc := rctx.(replyContext)
|
||||
if rc.isGroup {
|
||||
t.Error("direct routing: isGroup=true, want false for 1:1 session")
|
||||
}
|
||||
if rc.senderStaffId != "user111" {
|
||||
t.Errorf("direct routing: senderStaffId=%q, want %q", rc.senderStaffId, "user111")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// extractRichText tests (from main: richText message type support)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractRichText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content interface{}
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil content",
|
||||
content: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "non-map content",
|
||||
content: "not a map",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty richText array",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single text element",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"text": "Hello World"},
|
||||
},
|
||||
},
|
||||
want: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "multiple text elements",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"text": "Hello "},
|
||||
map[string]interface{}{"text": "World"},
|
||||
},
|
||||
},
|
||||
want: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "text with attrs (bold etc) — attrs ignored, text extracted",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"text": "normal "},
|
||||
map[string]interface{}{"text": "bold", "attrs": map[string]interface{}{"bold": true}},
|
||||
},
|
||||
},
|
||||
want: "normal bold",
|
||||
},
|
||||
{
|
||||
name: "mixed text and picture elements — pictures extracted",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"text": "See image: "},
|
||||
map[string]interface{}{"pictureDownloadCode": "abc123"},
|
||||
map[string]interface{}{"text": "done"},
|
||||
},
|
||||
},
|
||||
want: "See image: done",
|
||||
},
|
||||
{
|
||||
name: "missing richText key",
|
||||
content: map[string]interface{}{
|
||||
"other": "data",
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, _ := extractRichText(tt.content)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractRichText() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRichText_ImageDownloadCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content map[string]interface{}
|
||||
wantCodes []string
|
||||
}{
|
||||
{
|
||||
name: "single image",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"text": "Look: "},
|
||||
map[string]interface{}{"pictureDownloadCode": "img_001"},
|
||||
},
|
||||
},
|
||||
wantCodes: []string{"img_001"},
|
||||
},
|
||||
{
|
||||
name: "multiple images",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"pictureDownloadCode": "img_001"},
|
||||
map[string]interface{}{"text": " and "},
|
||||
map[string]interface{}{"pictureDownloadCode": "img_002"},
|
||||
},
|
||||
},
|
||||
wantCodes: []string{"img_001", "img_002"},
|
||||
},
|
||||
{
|
||||
name: "no images",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"text": "just text"},
|
||||
},
|
||||
},
|
||||
wantCodes: nil,
|
||||
},
|
||||
{
|
||||
name: "empty pictureDownloadCode ignored",
|
||||
content: map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"pictureDownloadCode": ""},
|
||||
},
|
||||
},
|
||||
wantCodes: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, gotCodes := extractRichText(tt.content)
|
||||
if len(gotCodes) != len(tt.wantCodes) {
|
||||
t.Fatalf("got %d codes, want %d: %v", len(gotCodes), len(tt.wantCodes), gotCodes)
|
||||
}
|
||||
for i, code := range gotCodes {
|
||||
if code != tt.wantCodes[i] {
|
||||
t.Errorf("code[%d] = %q, want %q", i, code, tt.wantCodes[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Token expiry fallback when server returns missing/invalid expireIn
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// fakeAccessTokenRT serves a single canned /oauth2/accessToken response
|
||||
// regardless of the request URL — enough to exercise getAccessToken's
|
||||
// caching arithmetic without hitting the real DingTalk API.
|
||||
type fakeAccessTokenRT struct {
|
||||
body string
|
||||
}
|
||||
|
||||
func (f *fakeAccessTokenRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(f.body)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestOnRawMessage_PictureMsgTypeNotDroppedAsEmptyText(t *testing.T) {
|
||||
// Regression test for #1128: DingTalk sometimes sends msgtype="picture"
|
||||
// for image messages. Before the fix, this fell through to the text handler,
|
||||
// which produced an empty-content message that was silently dropped by the engine.
|
||||
// After the fix, the message is routed to handleImageMessage instead.
|
||||
//
|
||||
// We cannot easily mock the full HTTP download path here. The test verifies the
|
||||
// negative: for msgtype="picture" the handler must NOT be called with empty content.
|
||||
// handleImageMessage may panic on the nil httpClient; we recover from that.
|
||||
var handlerCalledWithEmptyContent bool
|
||||
|
||||
func() {
|
||||
defer func() { recover() }() // handleImageMessage panics on nil httpClient — that's OK
|
||||
p := &Platform{
|
||||
handler: func(_ core.Platform, msg *core.Message) {
|
||||
if msg.Content == "" && len(msg.Images) == 0 {
|
||||
handlerCalledWithEmptyContent = true
|
||||
}
|
||||
},
|
||||
}
|
||||
p.onRawMessage(`{
|
||||
"msgtype": "picture",
|
||||
"msgId": "msg-pic-1",
|
||||
"conversationType": "1",
|
||||
"conversationId": "conv-1",
|
||||
"conversationTitle": "test",
|
||||
"senderStaffId": "user-1",
|
||||
"senderNick": "Alice",
|
||||
"sessionWebhook": "https://example.invalid/webhook",
|
||||
"content": {"downloadCode": "some-code"}
|
||||
}`)
|
||||
}()
|
||||
|
||||
// The message is routed to handleImageMessage (which fails at download — no mock),
|
||||
// not to the text handler. The handler must NOT be invoked with empty content.
|
||||
if handlerCalledWithEmptyContent {
|
||||
t.Error("msgtype=picture: handler called with empty content (image was silently dropped as text)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccessToken_ZeroExpireIn_FallsBackToDefault(t *testing.T) {
|
||||
p := &Platform{
|
||||
clientID: "test_client",
|
||||
clientSecret: "test_secret",
|
||||
httpClient: &http.Client{
|
||||
Transport: &fakeAccessTokenRT{body: `{"accessToken":"tok-zero","expireIn":0}`},
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
tok, err := p.getAccessToken()
|
||||
if err != nil {
|
||||
t.Fatalf("getAccessToken() error = %v", err)
|
||||
}
|
||||
if tok != "tok-zero" {
|
||||
t.Fatalf("token = %q, want %q", tok, "tok-zero")
|
||||
}
|
||||
|
||||
// Without the fallback, tokenExpiry would land at "before" (now+0s), making
|
||||
// time.Now().Before(tokenExpiry) immediately false — every subsequent call
|
||||
// would re-fetch a token. Assert the cache window is meaningful (>= 1h).
|
||||
gotWindow := p.tokenExpiry.Sub(before)
|
||||
if gotWindow < time.Hour {
|
||||
t.Errorf("tokenExpiry window = %v from response, want >= 1h (zero-expireIn should fall back, not cache for 0s)", gotWindow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccessToken_NegativeExpireIn_FallsBackToDefault(t *testing.T) {
|
||||
p := &Platform{
|
||||
clientID: "test_client",
|
||||
clientSecret: "test_secret",
|
||||
httpClient: &http.Client{
|
||||
Transport: &fakeAccessTokenRT{body: `{"accessToken":"tok-neg","expireIn":-1}`},
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
if _, err := p.getAccessToken(); err != nil {
|
||||
t.Fatalf("getAccessToken() error = %v", err)
|
||||
}
|
||||
if p.tokenExpiry.Sub(before) < time.Hour {
|
||||
t.Errorf("tokenExpiry window for expireIn=-1 = %v, want >= 1h", p.tokenExpiry.Sub(before))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccessToken_NormalExpireIn_AppliesBuffer(t *testing.T) {
|
||||
p := &Platform{
|
||||
clientID: "test_client",
|
||||
clientSecret: "test_secret",
|
||||
httpClient: &http.Client{
|
||||
Transport: &fakeAccessTokenRT{body: `{"accessToken":"tok-7200","expireIn":7200}`},
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
if _, err := p.getAccessToken(); err != nil {
|
||||
t.Fatalf("getAccessToken() error = %v", err)
|
||||
}
|
||||
// 7200 - 300 buffer = 6900s = 115min. Allow tolerance for elapsed time.
|
||||
gotWindow := p.tokenExpiry.Sub(before)
|
||||
if gotWindow < 100*time.Minute || gotWindow > 116*time.Minute {
|
||||
t.Errorf("tokenExpiry window for expireIn=7200 = %v, want ~6900s (100-116min)", gotWindow)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user