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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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("", 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("", cardImageTestMediaID),
|
||||
fmt.Sprintf("", cardImageTestID),
|
||||
fmt.Sprintf("", 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", 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user