From cfe3271fa28f8601ae7307f2d67dc7983768392c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 17 Sep 2026 18:20:02 +0800 Subject: [PATCH] feat(dingtalk): fall back to the public image URL and report image ref shape The loopback fetch now degrades to the public URL ... --- deploy/test_switch_card_image_media.sh | 67 +++++++++++++++++ .../channels/dingtalk/card_diagnostics.go | 19 +++-- .../channels/dingtalk/card_image_media.go | 71 ++++++++++++++++--- .../dingtalk/card_image_media_test.go | 45 ++++++++++++ 4 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 deploy/test_switch_card_image_media.sh diff --git a/deploy/test_switch_card_image_media.sh b/deploy/test_switch_card_image_media.sh new file mode 100644 index 0000000..58d6c9f --- /dev/null +++ b/deploy/test_switch_card_image_media.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Test-server switch to the build that delivers card images as DingTalk media ids. +# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE +# substitution is exactly 1 before and exactly 1 after. Read-only readback at +# the end. No secrets are printed. +set -euo pipefail + +NEW_IMAGE="${NEW_IMAGE:-dobotai-dingtalk:local-dfc7c8dba956}" +RUNTIME_ENV="/opt/dobotai/container/runtime.env" +OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)" +ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-image-media" +STAMP="$(date +%Y%m%dT%H%M%S)" +BACKUP_DIR="/opt/dobotai/container/backups" +BACKUP="$BACKUP_DIR/runtime.env.before-card-image-media.$STAMP" + +echo "== preflight ==" +docker image inspect "$NEW_IMAGE" >/dev/null +[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; } +before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")" +[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; } +echo "old_image=$OLD_IMAGE" +echo "new_image=$NEW_IMAGE" +echo "rollback_tag=$ROLLBACK_TAG" + +echo "== tag rollback target ==" +if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then + echo "rollback tag already exists, keeping it (no overwrite)" +else + docker tag "$OLD_IMAGE" "$ROLLBACK_TAG" + echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG" +fi + +echo "== backup runtime.env ==" +mkdir -p "$BACKUP_DIR" +install -m 0600 "$RUNTIME_ENV" "$BACKUP" +echo "backup=$BACKUP" + +echo "== rewrite DOBOTAI_IMAGE ==" +tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)" +awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp" +after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")" +if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then + rm -f "$tmp" + echo "ABORT: post-edit verification failed (match=$after_count)" >&2 + exit 1 +fi +chmod 0644 "$tmp" +mv "$tmp" "$RUNTIME_ENV" +grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" + +echo "== recreate container ==" +cd /opt/dobotai/container +docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk + +echo "== readback ==" +sleep 3 +curl -fsS http://127.0.0.1:8090/healthz; echo +docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}' +for lit in oapi.dingtalk.com/media/upload DINGTALK_CARD_IMAGE_MEDIA; do + if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then + echo "literal $lit: PRESENT" + else + echo "literal $lit: ABSENT" + fi +done +docker logs dobotai-dingtalk --since 60s 2>&1 | tail -6 +echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)" \ No newline at end of file diff --git a/internal/channels/dingtalk/card_diagnostics.go b/internal/channels/dingtalk/card_diagnostics.go index 85dd3dd..d98711e 100644 --- a/internal/channels/dingtalk/card_diagnostics.go +++ b/internal/channels/dingtalk/card_diagnostics.go @@ -20,11 +20,14 @@ type cardContentShape struct { // Structural split state: where the answer body starts inside this exact // payload. bodyPrefixRunes > 0 means the update still carries narration in // front of the body (the reasoning text the card is supposed to hide). - bodyOK bool - bodyLine int - bodyPrefixRunes int - bannerPos int - firstHeadingLine int + bodyOK bool + bodyLine int + bodyPrefixRunes int + bannerPos int + firstHeadingLine int + imageRefs int + externalImageRefs int + mediaImageRefs int } func inspectCardContent(content string) cardContentShape { @@ -39,6 +42,7 @@ func inspectCardContent(content string) cardContentShape { return shape } shape.lines = strings.Count(content, "\n") + 1 + shape.imageRefs, shape.externalImageRefs, shape.mediaImageRefs = cardImageRefCounts(content) shape.bannerPos = strings.Index(content, reasoningBannerMarker) // The streaming path decorates every frame with the reasoning banner, so the @@ -93,7 +97,7 @@ func inspectCardContent(content string) cardContentShape { func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string { shape := inspectCardContent(content) return fmt.Sprintf( - "DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d body_ok=%t body_line=%d body_prefix_runes=%d banner_pos=%d first_heading_line=%d", + "DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d body_ok=%t body_line=%d body_prefix_runes=%d banner_pos=%d first_heading_line=%d image_refs=%d external_image_refs=%d media_image_refs=%d", isFinalize, isError, shape.bytes, @@ -108,5 +112,8 @@ func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string shape.bodyPrefixRunes, shape.bannerPos, shape.firstHeadingLine, + shape.imageRefs, + shape.externalImageRefs, + shape.mediaImageRefs, ) } diff --git a/internal/channels/dingtalk/card_image_media.go b/internal/channels/dingtalk/card_image_media.go index 8f8d6be..13baf66 100644 --- a/internal/channels/dingtalk/card_image_media.go +++ b/internal/channels/dingtalk/card_image_media.go @@ -292,22 +292,55 @@ func (r *cardImageMediaResolver) upload(ctx context.Context, rawURL string) (str } func (r *cardImageMediaResolver) fetchURL(rawURL string) string { - base := r.imageURLPrefix - target := rawURL - if r.fetchURLPrefix != "" { - base = r.fetchURLPrefix - } - if !strings.HasSuffix(base, "/") { - base += "/" + targets := r.fetchURLs(rawURL) + if len(targets) == 0 { + return rawURL } + return targets[0] +} + +// fetchURLs lists the download candidates for one of our published images: the +// loopback prefix first (cheap, stays inside the host) and the public URL as a +// fallback so a missing local route never turns into a missing card image. +func (r *cardImageMediaResolver) fetchURLs(rawURL string) []string { + relative := "" if strings.HasPrefix(rawURL, r.imageURLPrefix) { - target = base + strings.TrimPrefix(rawURL, r.imageURLPrefix) + relative = strings.TrimPrefix(rawURL, r.imageURLPrefix) } - return target + targets := make([]string, 0, 2) + if r.fetchURLPrefix != "" && relative != "" { + targets = append(targets, strings.TrimRight(r.fetchURLPrefix, "/")+"/"+relative) + } + if relative == "" || r.fetchURLPrefix == "" { + targets = append(targets, rawURL) + } else { + targets = append(targets, strings.TrimRight(r.imageURLPrefix, "/")+"/"+relative) + } + return targets } func (r *cardImageMediaResolver) fetch(ctx context.Context, rawURL string) ([]byte, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.fetchURL(rawURL), nil) + var firstErr error + for _, target := range r.fetchURLs(rawURL) { + data, contentType, err := r.fetchOnce(ctx, target) + if err == nil { + return data, contentType, nil + } + if firstErr == nil { + firstErr = err + } + if ctx.Err() != nil { + break + } + } + if firstErr == nil { + firstErr = errors.New("card image fetch: no candidate URL") + } + return nil, "", firstErr +} + +func (r *cardImageMediaResolver) fetchOnce(ctx context.Context, target string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return nil, "", err } @@ -336,6 +369,24 @@ func (r *cardImageMediaResolver) fetch(ctx context.Context, rawURL string) ([]by return data, contentType, nil } +// cardImageRefCounts splits the markdown image references of a card body into +// externally hosted URLs and DingTalk media ids, so operators can tell from the +// logs whether a card body can actually render its images. +func cardImageRefCounts(content string) (total, external, media int) { + for _, match := range cardMarkdownImageRE.FindAllStringSubmatch(content, -1) { + if len(match) < 2 { + continue + } + total++ + if strings.Contains(strings.TrimSpace(match[1]), "://") { + external++ + } else { + media++ + } + } + return total, external, media +} + func (r *cardImageMediaResolver) accessToken(ctx context.Context) (string, error) { r.mu.Lock() if r.token != "" && r.now().Before(r.tokenExpiry) { diff --git a/internal/channels/dingtalk/card_image_media_test.go b/internal/channels/dingtalk/card_image_media_test.go index 33a5d8f..3265548 100644 --- a/internal/channels/dingtalk/card_image_media_test.go +++ b/internal/channels/dingtalk/card_image_media_test.go @@ -351,3 +351,48 @@ func TestCardFinalizeFallbackKeepsExternalImageURL(t *testing.T) { t.Fatalf("markdown fallback must not carry a media id, got=%q", reply.lastText) } } + +func TestCardImageMediaFallsBackToPublicURLWhenLoopbackMisses(t *testing.T) { + fixture := newCardImageTestServer(t) + rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) { + // The loopback route exists but does not serve this object. + opts.FetchURLPrefix = fixture.server.URL + "/missing/" + }) + content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID)) + + got := rewriter.RewriteForCard(context.Background(), content, true) + if !strings.Contains(got, cardImageTestMediaID) { + t.Fatalf("public fallback must still produce a media id, got=%q", got) + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/dingtalk/i/") { + t.Fatalf("fetched=%v want a single public fetch after the loopback miss", fixture.fetched) + } +} + +func TestCardImageRefCountsSplitMediaAndExternalReferences(t *testing.T) { + content := strings.Join([]string{ + fmt.Sprintf("![图一](%s)", cardImageTestMediaID), + fmt.Sprintf("![图二](https://weknora-api.dobot.cn/dingtalk/i/%s)", cardImageTestID), + fmt.Sprintf("![图三](%s)", cardImageTestMediaID), + }, "\n") + total, external, media := cardImageRefCounts(content) + if total != 3 || external != 1 || media != 2 { + t.Fatalf("total=%d external=%d media=%d want 3/1/2", total, external, media) + } + if total, external, media := cardImageRefCounts("无图回答"); total != 0 || external != 0 || media != 0 { + t.Fatalf("plain text must report no image refs, got %d/%d/%d", total, external, media) + } +} + +func TestCardUpdateDiagnosticReportsImageReferenceShape(t *testing.T) { + content := fmt.Sprintf("正文\n\n![图](%s)", cardImageTestMediaID) + got := formatCardUpdateDiagnostic(content, true, false) + if !strings.Contains(got, "image_refs=1") || !strings.Contains(got, "media_image_refs=1") || !strings.Contains(got, "external_image_refs=0") { + t.Fatalf("diagnostic must report image reference shape: %q", got) + } + if strings.Contains(got, cardImageTestMediaID) { + t.Fatalf("diagnostic must not leak the media id: %q", got) + } +}