Files
dingtalk-workspace-cli/internal/helpers/connect_card.go

581 lines
21 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
)
// AI-card reply support for the stream-bridge channels: instead of a plain
// one-shot text message, the bot answers with a DingTalk AI card that shows a
// "Thinking" state while the local agent runs and flips to done with the
// final content — the same UX the hermes official pipeline provides.
//
// The experience combines the two verified official implementations:
// - the "🤔Thinking/🥳Done" chip on the user's message is a text emotion
// (message reaction), ported from the hermes gateway adapter;
// - the card itself uses the openclaw connector's public template and
// payload contract (msgContent + flowStatus state machine), because card
// templates are app-scoped: hermes' own template (c629162a-...) renders
// "内容加载失败" for any other app — confirmed by A/B on the same robot.
//
// Call order: create instance → deliver → flowStatus INPUTING → streaming
// frames (throttled, isFull) → finalize frame → flowStatus FINISHED.
const (
// defaultAICardTemplateID is the public AI-card streaming template the
// openclaw connector ships with (messaging/card.ts) — a best-effort
// default. Card templates are APP-SCOPED: the reliable production setup is
// registering an AI-card template under YOUR app in the DingTalk developer
// console (the hermes docs prescribe exactly this) and passing its ID via
// --card-template / DWS_CARD_TEMPLATE. Using another app's template (e.g.
// hermes' c629162a-...) renders "内容加载失败" — confirmed by live A/B on
// the same robot.
defaultAICardTemplateID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema"
// aiCardMaxContent mirrors hermes' MAX_MESSAGE_LENGTH truncation.
aiCardMaxContent = 20000
// flowStatus states of the AI-card template (openclaw AICardStatus).
aiCardFlowInputing = "2"
aiCardFlowFinished = "3"
aiCardFlowFailed = "5"
)
// dingtalkCardAPIBase is a var so tests can point it at a httptest server.
var dingtalkCardAPIBase = "https://api.dingtalk.com"
// aiCardClient creates and finalizes AI cards using the robot's own
// credentials (clientId/clientSecret → app access token, cached ~2h).
type aiCardClient struct {
clientID string
clientSecret string
templateID string
httpClient *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
// newAICardClient builds the reply-UX client. With an empty templateID the
// client does emotions only (Thinking/Done chips) and no cards — mirroring
// hermes, where cards are an opt-in enabled by configuring a template ID and
// replies stay plain text otherwise. This avoids the silent-failure trap:
// card APIs all succeed even when the client cannot render the template.
func newAICardClient(clientID, clientSecret, templateID string) *aiCardClient {
templateID = strings.TrimSpace(templateID)
return &aiCardClient{
clientID: clientID,
clientSecret: clientSecret,
templateID: templateID,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// hasTemplate reports whether card replies are enabled (a template is
// configured); without one the client is used for emotions only.
func (c *aiCardClient) hasTemplate() bool { return c.templateID != "" }
// aiCardInstance is one delivered card, identified by its outTrackId.
type aiCardInstance struct {
outTrackID string
// inputing marks that the INPUTING flow state was already set (once per
// card, before the first streaming frame).
inputing bool
// lastFrame timestamps the last non-final streaming frame for the
// per-card throttle (the streaming endpoint 403s on <~500ms updates;
// hermes uses 800ms).
lastFrame time.Time
}
// accessToken returns a cached app access token, refreshing 5 minutes early.
func (c *aiCardClient) accessToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.tokenExp.Add(-5*time.Minute)) {
return c.token, nil
}
body, _ := json.Marshal(map[string]string{
"appKey": c.clientID,
"appSecret": c.clientSecret,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
dingtalkCardAPIBase+"/v1.0/oauth2/accessToken", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", fmt.Errorf("accessToken HTTP %d: %s", resp.StatusCode, truncateRunes(string(raw), 200))
}
var parsed struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if err := json.Unmarshal(raw, &parsed); err != nil || parsed.AccessToken == "" {
return "", fmt.Errorf("accessToken parse failed: %s", truncateRunes(string(raw), 200))
}
c.token = parsed.AccessToken
expire := parsed.ExpireIn
if expire <= 0 {
expire = 7200
}
c.tokenExp = time.Now().Add(time.Duration(expire) * time.Second)
return c.token, nil
}
// call performs one authenticated card-API request and fails on non-2xx.
func (c *aiCardClient) call(ctx context.Context, method, path string, payload map[string]any) error {
_, err := c.callRaw(ctx, method, path, payload)
return err
}
func (c *aiCardClient) callRaw(ctx context.Context, method, path string, payload map[string]any) (string, error) {
token, err := c.accessToken(ctx)
if err != nil {
return "", err
}
var bodyReader io.Reader
if payload != nil {
body, merr := json.Marshal(payload)
if merr != nil {
return "", merr
}
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bodyReader)
if err != nil {
return "", err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return "", fmt.Errorf("%s %s HTTP %d: %s", method, path, resp.StatusCode, truncateRunes(string(raw), 300))
}
// Surface every response body in the connector log: the card APIs report
// business-level failures inside a 200, and a silently dropped frame is
// otherwise indistinguishable from the client-side "内容加载失败".
fmt.Fprintf(os.Stderr, "[connect][card][api] %s %s -> %d %s\n", method, path, resp.StatusCode, truncateRunes(string(raw), 200))
return string(raw), nil
}
// sendOTOText sends a plain-text 1:1 (robot → person) message to each userId,
// using the robot batch one-to-one send API. It is how the connector reaches a
// specific person (e.g. the digital-twin owner) proactively, outside the
// ephemeral inbound sessionWebhook — the webhook can only reply into the
// conversation a message arrived on, and may be stale by the time an owner
// decides. Auth reuses the app access token (the bot's own clientId/secret), so
// the message is sent as the bot in its own org.
func (c *aiCardClient) sendOTOText(ctx context.Context, userIDs []string, text string) error {
if len(userIDs) == 0 {
return nil
}
param, _ := json.Marshal(map[string]string{"content": text})
return c.call(ctx, http.MethodPost, "/v1.0/robot/oToMessages/batchSend", map[string]any{
"robotCode": c.clientID,
"userIds": userIDs,
"msgKey": "sampleText",
"msgParam": string(param),
})
}
// createAndDeliver creates an AI-card instance and delivers it into the
// conversation the message came from. With empty content the template
// renders the "Thinking" state while the agent runs. Group messages deliver
// to IM_GROUP (with robotCode), 1:1 to IM_ROBOT (spaceType only) — exactly
// the hermes deliver shapes.
func (c *aiCardClient) createAndDeliver(ctx context.Context, data *chatbot.BotCallbackDataModel) (*aiCardInstance, error) {
outTrackID := "dws_" + uuid.NewString()
create := map[string]any{
"cardTemplateId": c.templateID,
"outTrackId": outTrackID,
"cardData": map[string]any{
"cardParamMap": map[string]any{"config": `{"autoLayout":true}`},
},
"callbackType": "STREAM",
"imGroupOpenSpaceModel": map[string]any{"supportForward": true},
"imRobotOpenSpaceModel": map[string]any{"supportForward": true},
}
if err := c.call(ctx, http.MethodPost, "/v1.0/card/instances", create); err != nil {
return nil, err
}
deliver := map[string]any{
"outTrackId": outTrackID,
"userIdType": 1,
}
if data.ConversationType == "2" { // group chat
deliver["openSpaceId"] = "dtv1.card//IM_GROUP." + data.ConversationId
deliver["imGroupOpenDeliverModel"] = map[string]any{"robotCode": c.clientID}
} else { // 1:1 chat with the robot
if data.SenderStaffId == "" {
return nil, fmt.Errorf("missing senderStaffId for 1:1 card delivery")
}
deliver["openSpaceId"] = "dtv1.card//IM_ROBOT." + data.SenderStaffId
deliver["imRobotOpenDeliverModel"] = map[string]any{
"spaceType": "IM_ROBOT",
"robotCode": c.clientID,
"extension": map[string]any{"dynamicSummary": "true"},
}
}
if err := c.callChecked(ctx, http.MethodPost, "/v1.0/card/instances/deliver", deliver); err != nil {
return nil, err
}
return &aiCardInstance{outTrackID: outTrackID}, nil
}
// callChecked is call() plus a business-level result check: the deliver API
// reports per-target failures INSIDE a HTTP 200 (e.g. {"result":[{"success":
// false,"errorMsg":"spaceId is illegal"}],"success":true}) — observed live.
func (c *aiCardClient) callChecked(ctx context.Context, method, path string, payload map[string]any) error {
raw, err := c.callRaw(ctx, method, path, payload)
if err != nil {
return err
}
if strings.Contains(raw, `"success":false`) {
return fmt.Errorf("%s %s business failure: %s", method, path, truncateRunes(raw, 300))
}
return nil
}
// streamingUpdate sends one full content frame to the card.
func (c *aiCardClient) streamingUpdate(ctx context.Context, card *aiCardInstance, content string, finalize, isError bool) error {
if r := []rune(content); len(r) > aiCardMaxContent {
content = string(r[:aiCardMaxContent])
}
return c.call(ctx, http.MethodPut, "/v1.0/card/streaming", map[string]any{
"outTrackId": card.outTrackID,
"guid": uuid.NewString(),
"key": "msgContent",
"content": content,
"isFull": true,
"isFinalize": finalize,
"isError": isError,
})
}
// aiCardFrameGap spaces the deliver → content → finalize frames. Delivering
// and finalizing back-to-back races the client's card fetch and intermittently
// renders "内容加载失败" (the very failure that killed the #407 card attempt);
// a short gap lets the client subscribe before the closing frame lands.
var aiCardFrameGap = 500 * time.Millisecond
var aiCardSleepCtx = sleepCtx
// cardContentParams is the cardParamMap contract of the openclaw template.
func cardContentParams(flowStatus, content string) map[string]any {
return map[string]any{
"flowStatus": flowStatus,
"msgContent": content,
"staticMsgContent": "",
"sys_full_json_obj": `{"order":["msgContent"]}`,
"config": `{"autoLayout":true}`,
}
}
// setFlowStatus updates the card instance's flow state (openclaw contract:
// INPUTING before streaming, FINISHED after the finalize frame).
func (c *aiCardClient) setFlowStatus(ctx context.Context, card *aiCardInstance, flowStatus, content string, byKey bool) error {
payload := map[string]any{
"outTrackId": card.outTrackID,
"cardData": map[string]any{"cardParamMap": cardContentParams(flowStatus, content)},
}
if byKey {
payload["cardUpdateOptions"] = map[string]any{"updateCardDataByKey": true}
}
return c.call(ctx, http.MethodPut, "/v1.0/card/instances", payload)
}
// aiCardFrameThrottle is the minimum spacing between non-final streaming
// frames per card (hermes uses 800ms; the endpoint 403s on rapid updates).
var aiCardFrameThrottle = 800 * time.Millisecond
// streamFrame pushes one full-content frame into the card, switching the card
// to INPUTING before the first frame. Non-final frames are throttled per
// card — a skipped frame is fine because every frame carries the full text so
// far (isFull). The final frame always goes out.
func (c *aiCardClient) streamFrame(ctx context.Context, card *aiCardInstance, content string, final bool) error {
normalized := normalizeForCard(content)
if !card.inputing {
if err := c.setFlowStatus(ctx, card, aiCardFlowInputing, normalized, false); err != nil {
return err
}
card.inputing = true
card.lastFrame = time.Now()
}
if !final {
if time.Since(card.lastFrame) < aiCardFrameThrottle {
return nil // skip; a later frame re-sends the full text
}
}
if err := c.streamingUpdate(ctx, card, normalized, final, false); err != nil {
return err
}
card.lastFrame = time.Now()
return nil
}
// finish closes the card: the finalized streaming frame plus the FINISHED
// flow state. A failure leaves the card spinning, so callers must treat an
// error as "fall back to a plain webhook reply" (after markFailed).
func (c *aiCardClient) finish(ctx context.Context, card *aiCardInstance, content string) error {
normalized := normalizeForCard(content)
if err := c.streamFrame(ctx, card, content, true); err != nil {
return err
}
if err := aiCardSleepCtx(ctx, aiCardFrameGap); err != nil {
return err
}
return c.setFlowStatus(ctx, card, aiCardFlowFinished, normalized, true)
}
// finalize is the one-shot path for channels without incremental output:
// INPUTING → final frame → FINISHED in one call.
func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, content string) error {
if err := aiCardSleepCtx(ctx, aiCardFrameGap); err != nil {
return err
}
return c.finish(ctx, card, content)
}
// repair re-pushes the finalize frame once, a few seconds after the reply.
// hermes streams many frames so a client that misses one recovers on the
// next; our burst of two frames has no such retry, and a missed fetch shows
// "内容加载失败" until a new frame arrives. Best-effort by design.
func (c *aiCardClient) repair(ctx context.Context, card *aiCardInstance, content string) {
if err := aiCardSleepCtx(ctx, aiCardRepairDelay); err != nil {
return
}
_ = c.setFlowStatus(ctx, card, aiCardFlowFinished, normalizeForCard(content), true)
}
// aiCardRepairDelay is how long after finalize the repair frame goes out.
var aiCardRepairDelay = 3 * time.Second
func sleepCtx(ctx context.Context, d time.Duration) error {
if d <= 0 {
return nil
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// markFailed best-effort closes a stuck card in the error state so the
// Thinking chip does not spin forever when finalize failed and the reply
// went out as a plain message instead. Errors are ignored by design.
func (c *aiCardClient) markFailed(ctx context.Context, card *aiCardInstance) {
_ = c.setFlowStatus(ctx, card, aiCardFlowFailed, "", true)
}
// ---- Message reactions (the "🤔Thinking" chip) ----
//
// The status chip attached to the user's message is NOT a card: it is a text
// emotion (message reaction) — hermes fires "🤔Thinking" on receive and swaps
// it for "🥳Done" when the reply lands (gateway/platforms/dingtalk.py,
// _send_emotion / _fire_done_reaction). During thinking there is no card at
// all; the card only appears with the final content.
const (
emotionThinking = "🤔Thinking"
emotionDone = "🥳Done"
)
// sendEmotion adds (or recalls) a text reaction on the user's message.
// Failures are logged by callers at most — the reaction is decoration, never
// worth failing the reply over. Payload contract per the robot_1_0 SDK:
// POST /v1.0/robot/emotion/reply | /v1.0/robot/emotion/recall.
func (c *aiCardClient) sendEmotion(ctx context.Context, conversationID, msgID, name string, recall bool) error {
if conversationID == "" || msgID == "" {
return fmt.Errorf("emotion needs openConversationId and openMsgId")
}
path := "/v1.0/robot/emotion/reply"
if recall {
path = "/v1.0/robot/emotion/recall"
}
return c.call(ctx, http.MethodPost, path, map[string]any{
"robotCode": c.clientID,
"openConversationId": conversationID,
"openMsgId": msgID,
"emotionType": 2,
"emotionName": name,
"textEmotion": map[string]any{
"emotionId": "2659900",
"emotionName": name,
"text": name,
"backgroundId": "im_bg_1",
},
})
}
// markThinking fires the "🤔Thinking" chip on the user's message.
func (c *aiCardClient) markThinking(ctx context.Context, conversationID, msgID string) error {
return c.sendEmotion(ctx, conversationID, msgID, emotionThinking, false)
}
// swapThinkingToDone replaces the chip with "🥳Done" after the reply landed.
// Best-effort by design (mirrors hermes' fire-and-forget swap).
func (c *aiCardClient) swapThinkingToDone(ctx context.Context, conversationID, msgID string) {
_ = c.sendEmotion(ctx, conversationID, msgID, emotionThinking, true)
_ = c.sendEmotion(ctx, conversationID, msgID, emotionDone, false)
}
// ---- Markdown normalization for the AI-card renderer ----
//
// Ported from openclaw's normalizeForCard (messaging/card.ts): the 02fcf2f4
// template renders <br> as visual line breaks in plain text, needs real \n
// inside code fences and before Markdown block syntax, and a blank line
// before tables.
var (
cardTableDividerRe = regexp.MustCompile(`^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$`)
cardTableRowRe = regexp.MustCompile(`^\s*\|?.*\|.*\|?\s*$`)
cardBlockStartRe = regexp.MustCompile(`^(\s{0,3}(?:[-*+]|\d+[.)])[ ])|(\s{0,3}\|)|(\s{0,3}#{1,6}\s)|(\s{0,3}(?:[-*_])\s*(?:[-*_])\s*(?:[-*_]))`)
cardFenceRe = regexp.MustCompile("^\\s{0,3}```")
cardQuoteRe = regexp.MustCompile(`^\s{0,3}>\s?`)
)
func normalizeCardLineEndings(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
return strings.ReplaceAll(text, "\r", "\n")
}
// ensureCardTableBlankLines inserts a blank line before a table header that
// directly follows non-table text, which the card renderer requires.
func ensureCardTableBlankLines(text string) string {
lines := strings.Split(normalizeCardLineEndings(text), "\n")
out := make([]string, 0, len(lines))
isDivider := func(line string) bool {
return strings.Contains(line, "|") && cardTableDividerRe.MatchString(line)
}
for i, line := range lines {
next := ""
if i+1 < len(lines) {
next = lines[i+1]
}
if cardTableRowRe.MatchString(line) && isDivider(next) && i > 0 &&
strings.TrimSpace(lines[i-1]) != "" && !cardTableRowRe.MatchString(lines[i-1]) {
out = append(out, "")
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// fixCardNewlines converts single \n to <br> for plain text while keeping
// real newlines inside code fences and before Markdown block syntax, and
// merges consecutive quote lines with <br> (lazy continuation).
func fixCardNewlines(text string) string {
normalized := normalizeCardLineEndings(text)
var merged []string
var pendingQuote []string
inCode := false
flushQuote := func() {
if len(pendingQuote) > 0 {
merged = append(merged, strings.Join(pendingQuote, "<br>"))
pendingQuote = nil
}
}
for _, line := range strings.Split(normalized, "\n") {
isFence := cardFenceRe.MatchString(line)
if inCode {
flushQuote()
merged = append(merged, line)
if isFence {
inCode = false
}
continue
}
if isFence {
flushQuote()
merged = append(merged, line)
inCode = true
continue
}
if cardQuoteRe.MatchString(line) {
if len(pendingQuote) == 0 {
pendingQuote = append(pendingQuote, line)
} else {
pendingQuote = append(pendingQuote, cardQuoteRe.ReplaceAllString(line, ""))
}
} else {
flushQuote()
merged = append(merged, line)
}
}
flushQuote()
inCode = false
var b strings.Builder
for i, line := range merged {
nextInCode := inCode
if cardFenceRe.MatchString(line) {
nextInCode = !inCode
}
if i < len(merged)-1 {
next := merged[i+1]
keepNewline := nextInCode || line == "" || next == "" ||
cardFenceRe.MatchString(next) || cardBlockStartRe.MatchString(next)
b.WriteString(line)
if keepNewline {
b.WriteString("\n")
} else {
b.WriteString("<br>")
}
} else {
b.WriteString(line)
}
inCode = nextInCode
}
return b.String()
}
// normalizeForCard prepares agent output for the AI-card renderer.
func normalizeForCard(content string) string {
return fixCardNewlines(ensureCardTableBlankLines(content))
}