Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fa2eac6a4 | |||
| da69226481 |
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user