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)"
@@ -25,6 +25,9 @@ type cardContentShape struct {
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,
)
}
+60 -9
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
targets := r.fetchURLs(rawURL)
if len(targets) == 0 {
return rawURL
}
if !strings.HasSuffix(base, "/") {
base += "/"
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)
}
}