feat: enable per-user DingTalk card debugging

This commit is contained in:
姚章浩
2026-07-21 18:02:14 +08:00
parent da69226481
commit 9fa2eac6a4
5 changed files with 182 additions and 12 deletions
+4
View File
@@ -22,6 +22,10 @@ DOBOTAI_TENANT_KEY=
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
DINGTALK_CARD_TEMPLATE_ID=
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
# Keep empty outside a controlled debug session; never commit a real user ID.
DINGTALK_CARD_DEBUG_USER_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
+11 -1
View File
@@ -92,10 +92,20 @@ func main() {
}
var cardSender dingtalk.FinalCardSender
if cardTemplateID := strings.TrimSpace(os.Getenv("DINGTALK_CARD_TEMPLATE_ID")); cardTemplateID != "" {
cs, err := dingtalk.NewCardSender(dingCfg.ClientID, dingCfg.ClientSecret, cardTemplateID, nil)
cardDebugUserID := os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")
cs, err := dingtalk.NewCardSender(
dingCfg.ClientID,
dingCfg.ClientSecret,
cardTemplateID,
nil,
dingtalk.WithCardDebugUserID(cardDebugUserID),
)
if err != nil {
log.Fatalf("DingTalk card sender init failed: %v", err)
}
if strings.TrimSpace(cardDebugUserID) != "" {
log.Printf("DingTalk card debug enabled for one allowlisted user")
}
cardSender = cs
}
handler, err := dingtalk.NewHandlerWithOptions(
+16
View File
@@ -85,3 +85,19 @@ func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
}
}
}
func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")`,
`dingtalk.WithCardDebugUserID(cardDebugUserID)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main card debug wiring missing %q", fragment)
}
}
}
+40 -11
View File
@@ -17,8 +17,9 @@ import (
)
const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
)
// CardRequest is the input for sending a final AI card.
@@ -39,26 +40,38 @@ type CardSender struct {
streamURL string
apiClient *http.Client
allowTestURL bool
debugUserID string
mu sync.Mutex
token string
tokenTill time.Time
}
// CardSenderOption configures optional CardSender behavior.
type CardSenderOption func(*CardSender)
// WithCardDebugUserID enables DingTalk's official card debug entry only for
// the matching user through per-user privateData. A blank value keeps it off.
func WithCardDebugUserID(userID string) CardSenderOption {
return func(sender *CardSender) {
sender.debugUserID = strings.TrimSpace(userID)
}
}
// NewCardSender creates a CardSender. All three config values are required
// after trimming whitespace. A nil http.Client installs a hardened default
// (no redirects, Proxy=nil, 15s timeout).
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false)
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, options ...CardSenderOption) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false, options...)
}
// newCardSenderForTest is used by tests to inject httptest URLs.
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string) (*CardSender, error) {
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string, options ...CardSenderOption) (*CardSender, error) {
streamURL := strings.TrimSuffix(createURL, "/create") + "/stream"
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true)
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true, options...)
}
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool) (*CardSender, error) {
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool, options ...CardSenderOption) (*CardSender, error) {
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
cardTemplateID = strings.TrimSpace(cardTemplateID)
@@ -91,7 +104,7 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
apiClient.Transport = transport
}
return &CardSender{
sender := &CardSender{
clientID: clientID,
clientSecret: clientSecret,
cardTemplateID: cardTemplateID,
@@ -100,7 +113,13 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
streamURL: streamURL,
apiClient: &apiClient,
allowTestURL: allowTest,
}, nil
}
for _, option := range options {
if option != nil {
option(sender)
}
}
return sender, nil
}
// SendFinalCard sends the AI card to DingTalk and returns the generated outTrackID.
@@ -134,9 +153,10 @@ func (s *CardSender) StartCard(ctx context.Context, req CardRequest) (string, er
return "", errors.New("dingtalk card: content exceeds 64 KiB")
}
// Validate identity fields
// Validate identity fields. ExternalUserID is also available for group
// askers and is used only for per-user card private data.
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ConversationType == core.ConversationDirect {
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ExternalUserID == "" {
return "", errors.New("dingtalk card: ExternalUserID is required for direct conversations")
}
@@ -232,6 +252,15 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"extension": map[string]any{},
}
}
if s.debugUserID != "" && req.ExternalUserID == s.debugUserID {
body["privateData"] = map[string]any{
req.ExternalUserID: map[string]any{
"cardParamMap": map[string]any{
cardDebugPrivateKey: "show",
},
},
}
}
return body
}
+111
View File
@@ -271,6 +271,117 @@ func TestCardSendDirectPayload(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: official DingTalk card debug private data for one allowlisted user
// ---------------------------------------------------------------------------
func TestCardSendDirectAddsDebugPrivateDataForSelectedUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id",
"client-secret",
"card-template-123",
ts.Client(),
ts.URL()+"/token",
ts.URL()+"/create",
WithCardDebugUserID(" user-debug "),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-debug",
Content: "debug this card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
userData, ok := privateData["user-debug"].(map[string]any)
if !ok {
t.Fatal("selected user's privateData missing or wrong type")
}
paramMap, ok := userData["cardParamMap"].(map[string]any)
if !ok {
t.Fatal("selected user's cardParamMap missing or wrong type")
}
if got := paramMap["_CARD_DEBUG_TOOL_ENTRY"]; got != "show" {
t.Fatalf("_CARD_DEBUG_TOOL_ENTRY=%v, want show", got)
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
func TestCardSendOmitsDebugPrivateDataForOtherUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-other",
Content: "ordinary card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
if _, ok := ts.lastCreateBody["privateData"]; ok {
t.Fatal("privateData must be absent for a non-allowlisted user")
}
}
func TestCardSendGroupAddsDebugPrivateDataOnlyForAsker(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationGroup,
ExternalUserID: " user-debug ",
ExternalChatID: "group-789",
Content: "group debug card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
if _, ok := privateData["user-debug"]; !ok {
t.Fatal("group asker's privateData missing")
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
// ---------------------------------------------------------------------------
// Test: group conversation full payload
// ---------------------------------------------------------------------------