feat(dingtalk): fall back to the public image URL and report image ref shape

The loopback fetch now degrades to the public URL ...
This commit is contained in:
姚章浩
2026-09-17 18:20:02 +08:00
parent dfc7c8dba9
commit cfe3271fa2
4 changed files with 186 additions and 16 deletions
+67
View File
@@ -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)"
+13 -6
View File
@@ -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,
)
}
+61 -10
View File
@@ -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) {
@@ -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)
}
}