diff --git a/cmd/dobotai/card_tables.go b/cmd/dobotai/card_tables.go new file mode 100644 index 0000000..5a45e78 --- /dev/null +++ b/cmd/dobotai/card_tables.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "os" + "strings" +) + +// cardWideTableGroupingMode returns the configured handling of over-wide answer +// tables (see dingtalk.tableGroupingForMode): +// +// unset / on → grouped list on every frame (historical behaviour) +// off → real tables on every frame +// stream → grouped list while streaming, real table on the final frame +// +// The default stays "on" so an environment without the variable renders exactly +// as before; DINGTALK_CARD_WIDE_TABLE_GROUP selects the other modes. +func cardWideTableGroupingMode() string { + mode := strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP"))) + switch mode { + case "off", "0", "false", "no", "stream", "streaming", "partial": + log.Printf("DingTalk wide-table grouping mode=%s", mode) + return mode + default: + return "" + } +} diff --git a/cmd/dobotai/main.go b/cmd/dobotai/main.go index 5576d07..baf825b 100644 --- a/cmd/dobotai/main.go +++ b/cmd/dobotai/main.go @@ -71,7 +71,10 @@ func main() { if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" { log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment") } - client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second) + // 0 = no client-side total timeout. WeKnora's smart-reasoning agent can run + // 20+ tool rounds and stream far longer than 120s; the total-time cap cut the + // SSE mid-answer and the failure branch overwrote the streamed card. + client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 0) if err != nil { log.Fatal(err) } @@ -307,6 +310,7 @@ func main() { } cardSender = cs } + cardImageMedia := buildCardImageMedia(outboundImages.publicURLPrefix, dingCfg.ClientID, dingCfg.ClientSecret, address) handler, err := dingtalk.NewHandlerWithOptions( dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"}, qaService, @@ -317,9 +321,13 @@ func main() { CardSender: cardSender, Directory: directoryResolver, OutboundRewriter: outboundImages.rewriter, + CardImageMedia: cardImageMedia, SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller}, ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService}, LanguageResolver: settingsRuntime.repository, + + OutboundRewriteTimeout: outboundImages.rewriteTimeout, + WideTableGrouping: cardWideTableGroupingMode(), }, ) if err != nil { diff --git a/cmd/dobotai/main_test.go b/cmd/dobotai/main_test.go index 2217dea..e48b876 100644 --- a/cmd/dobotai/main_test.go +++ b/cmd/dobotai/main_test.go @@ -5,6 +5,7 @@ import ( "os" "strings" "testing" + "time" "gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit" ) @@ -184,6 +185,55 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) { } } +func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) { + if outboundImageRewriteTimeoutDefault != 180*time.Second { + t.Fatalf("default rewrite budget=%s", outboundImageRewriteTimeoutDefault) + } + t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "") + if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault { + t.Fatalf("unset budget=%s", got) + } + t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "2m30s") + if got := outboundImageRewriteTimeout(); got != 150*time.Second { + t.Fatalf("parsed budget=%s", got) + } + for _, invalid := range []string{"-1s", "180", "soon"} { + t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", invalid) + if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault { + t.Fatalf("invalid budget %q resolved to %s", invalid, got) + } + } + t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", "") + if !outboundImageCompressionEnabled() { + t.Fatal("compression must default to on") + } + for _, disabled := range []string{"off", "0", "false", "no"} { + t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", disabled) + if outboundImageCompressionEnabled() { + t.Fatalf("compression must be off for %q", disabled) + } + } +} + +func TestCardWideTableGroupingMode(t *testing.T) { + t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "") + if got := cardWideTableGroupingMode(); got != "" { + t.Fatalf("unset must keep the historical mode, got %q", got) + } + t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off") + if got := cardWideTableGroupingMode(); got != "off" { + t.Fatalf("off must be passed through, got %q", got) + } + t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", " STREAM ") + if got := cardWideTableGroupingMode(); got != "stream" { + t.Fatalf("stream must be normalised, got %q", got) + } + t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "on") + if got := cardWideTableGroupingMode(); got != "" { + t.Fatalf("on is the default and must not be forwarded as a mode, got %q", got) + } +} + func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) { data, err := os.ReadFile("main.go") if err != nil { diff --git a/cmd/dobotai/outbound_images.go b/cmd/dobotai/outbound_images.go index d7a636d..bed2af5 100644 --- a/cmd/dobotai/outbound_images.go +++ b/cmd/dobotai/outbound_images.go @@ -2,7 +2,11 @@ package main import ( "errors" + "log" + "net" "net/http" + "os" + "strconv" "strings" "time" @@ -11,12 +15,63 @@ import ( ) const ( - outboundImageMaxCount = 5 - outboundImageMaxBytes = 10 << 20 - outboundImagePrefix = "dobotai/im-images" - outboundImageRoute = "/dingtalk/i/" + outboundImageMaxCountDefault = 12 + outboundImageMaxBytes = 10 << 20 + outboundImagePrefix = "dobotai/im-images" + outboundImageRoute = "/dingtalk/i/" + // outboundImageRewriteTimeoutDefault is the budget for one answer's image + // rewrite. Manual screenshots are multi-megabyte uploads and the container's + // uplink is the real ceiling (measured ~0.6MB/s), so long answers need a + // budget with headroom: the historical 15s delivery timeout truncated them. + outboundImageRewriteTimeoutDefault = 180 * time.Second ) +// outboundImageMaxCount resolves how many images one answer may rewrite. +// DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT overrides the default; invalid values fall back. +func outboundImageMaxCount() int { + raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT")) + if raw == "" { + return outboundImageMaxCountDefault + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT=%q; using default %d", raw, outboundImageMaxCountDefault) + return outboundImageMaxCountDefault + } + return n +} + +// outboundImageRewriteTimeout resolves the budget for the image rewrite stage. +// DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT accepts a Go duration ("90s", "2m"); +// invalid or non-positive values fall back to the default. +func outboundImageRewriteTimeout() time.Duration { + raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT")) + if raw == "" { + return outboundImageRewriteTimeoutDefault + } + duration, err := time.ParseDuration(raw) + if err != nil || duration <= 0 { + log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT=%q; using default %s", raw, outboundImageRewriteTimeoutDefault) + return outboundImageRewriteTimeoutDefault + } + return duration +} + +// outboundImageCompressionEnabled reports whether published screenshots are +// re-encoded as smaller JPEGs before upload. It is on by default because the +// container uplink, not the rewrite code, caps how many images fit in the +// delivery budget; DOBOTAI_OUTBOUND_IMAGE_COMPRESS=off restores the original +// bytes exactly as fetched. +func outboundImageCompressionEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS"))) { + case "off", "0", "false", "no": + log.Printf("outbound image: compression disabled by configuration") + return false + default: + return true + } +} + type outboundImageConfig struct { WeKnoraBaseURL string WeKnoraAPIKey string @@ -32,6 +87,11 @@ type outboundImageRuntime struct { rewriter dingtalk.OutboundContentRewriter publisher *outboundimage.StableHashPublisher handler http.Handler + // rewriteTimeout is the budget the channel gives the image rewrite stage. + rewriteTimeout time.Duration + // publicURLPrefix is the prefix of every published image URL, used by the + // DingTalk card image uploader to recognise (and refetch) our own images. + publicURLPrefix string } func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) { @@ -81,9 +141,11 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e return outboundImageRuntime{}, err } return outboundImageRuntime{ - rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount), - publisher: publisher, - handler: handler, + rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount(), outboundimage.WithImageCompression(outboundImageCompressionEnabled())), + publisher: publisher, + handler: handler, + rewriteTimeout: outboundImageRewriteTimeout(), + publicURLPrefix: strings.TrimRight(cfg.PublicBaseURL, "/") + "/", }, nil } @@ -91,3 +153,49 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte runtime, err := buildOutboundImageRuntime(cfg) return runtime.rewriter, err } + +// buildCardImageMedia wires the DingTalk media-id rewriter used for AI card +// bodies. It returns nil when the feature is switched off or not configured, so +// a disabled uploader simply keeps external image URLs in place. +func buildCardImageMedia(publicImagePrefix, clientID, clientSecret, listenAddr string) dingtalk.CardImageMediaRewriter { + switch strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_MEDIA"))) { + case "off", "0", "false", "no": + log.Printf("DingTalk card image media ids disabled by configuration") + return nil + } + resolver, err := dingtalk.NewCardImageMediaRewriter(dingtalk.CardImageMediaOptions{ + ClientID: clientID, + ClientSecret: clientSecret, + ImageURLPrefix: publicImagePrefix, + FetchURLPrefix: cardImageFetchPrefix(listenAddr), + }) + if err != nil { + log.Printf("DingTalk card image media ids disabled: %v", err) + return nil + } + if resolver == nil { + return nil + } + log.Printf("DingTalk card images will be delivered through DingTalk media ids") + return resolver +} + +// cardImageFetchPrefix derives the prefix used to download our own published +// images before uploading them to DingTalk. An explicit +// DINGTALK_CARD_IMAGE_FETCH_BASE_URL (host only, without the image route) wins; +// otherwise the local listen port is reused so the fetch stays on loopback. +func cardImageFetchPrefix(listenAddr string) string { + base := strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_FETCH_BASE_URL")) + if base == "" { + addr := strings.TrimSpace(listenAddr) + if addr == "" { + return "" + } + _, port, err := net.SplitHostPort(addr) + if err != nil || port == "" { + return "" + } + base = "http://127.0.0.1:" + port + } + return strings.TrimRight(base, "/") + outboundImageRoute +} diff --git a/cmd/imgprobe/main.go b/cmd/imgprobe/main.go new file mode 100644 index 0000000..e953893 --- /dev/null +++ b/cmd/imgprobe/main.go @@ -0,0 +1,118 @@ +// 临时探针(不入库,用完即删):用真实 WeKnora + OSS 端点跑 +// internal/outboundimage 的改写链路,量化"一条含 N 张真实图的答案"在 +// 并发取图 + 预算可配下的出图结果与耗时。 +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "regexp" + "strings" + "time" + + "gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage" +) + +func main() { + refsFile := flag.String("refs-file", "", "文件:每行一个 resource:// 句柄") + budget := flag.Duration("budget", 180*time.Second, "改写预算") + maxImages := flag.Int("max", 50, "图片上限") + compress := flag.Bool("compress", true, "上传前压缩截图") + passes := flag.Int("passes", 1, "重复改写轮数(第二轮应命中已存在对象)") + flag.Parse() + + refs := readRefs(*refsFile) + if len(refs) == 0 { + log.Fatal("probe: no references") + } + + fetcher, err := outboundimage.NewWeKnoraFetcher( + os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), + &http.Client{Timeout: 30 * time.Second}, 10<<20) + if err != nil { + log.Fatalf("probe: fetcher: %v", err) + } + store, err := outboundimage.NewAliyunOSSStore( + os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"), + os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"), + os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"), + os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"), + os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET")) + if err != nil { + log.Fatalf("probe: store: %v", err) + } + publicBase := os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL") + publisher, err := outboundimage.NewStableHashPublisher(store, "dobotai/im-images", publicBase) + if err != nil { + log.Fatalf("probe: publisher: %v", err) + } + rewriter := outboundimage.NewRewriter(fetcher, publisher, *maxImages, outboundimage.WithImageCompression(*compress)) + + var answer strings.Builder + answer.WriteString("## 探针答案\n") + for i, ref := range refs { + fmt.Fprintf(&answer, "\n![图%d](%s)\n", i+1, ref) + } + + log.Printf("probe: refs=%d budget=%s max=%d compress=%v passes=%d bucket=%s", len(refs), *budget, *maxImages, *compress, *passes, os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET")) + body := answer.String() + for pass := 1; pass <= *passes; pass++ { + ctx, cancel := context.WithTimeout(context.Background(), *budget) + started := time.Now() + out := rewriter.Rewrite(ctx, body) + elapsed := time.Since(started) + cancel() + + published := regexp.MustCompile(`!\[图\d+\]\(https://`).FindAllString(out, -1) + leaked := strings.Count(out, "resource://") + fmt.Printf("RESULT pass=%d elapsed=%s refs=%d published=%d dropped=%d residual_resource_refs=%d out_bytes=%d\n", + pass, elapsed.Round(time.Millisecond), len(refs), len(published), len(refs)-len(published), leaked, len(out)) + if len(published) > 0 { + fmt.Printf("FIRST_URL_%d=%s\nLAST_URL_%d=%s\n", pass, firstImageURL(out), pass, lastImageURL(out)) + } + } +} + +func readRefs(path string) []string { + if path == "" { + return nil + } + file, err := os.Open(path) + if err != nil { + log.Fatalf("probe: refs file: %v", err) + } + defer file.Close() + seen := map[string]bool{} + refs := make([]string, 0, 64) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + ref := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(ref, "resource://") || seen[ref] { + continue + } + seen[ref] = true + refs = append(refs, ref) + } + return refs +} + +var imageURLRE = regexp.MustCompile(`!\[图\d+\]\((https://[^)]+)\)`) + +func firstImageURL(content string) string { + if match := imageURLRE.FindStringSubmatch(content); len(match) > 1 { + return match[1] + } + return "" +} + +func lastImageURL(content string) string { + if matches := imageURLRE.FindAllStringSubmatch(content, -1); len(matches) > 0 { + return matches[len(matches)-1][1] + } + return "" +} \ No newline at end of file diff --git a/deploy/card_widescreen_probe.py b/deploy/card_widescreen_probe.py new file mode 100644 index 0000000..80ebfed --- /dev/null +++ b/deploy/card_widescreen_probe.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""One-shot DingTalk AI card probe (test bot, private IM_ROBOT space). + +Sends two cards to the owner's private robot chat so PC width can be compared: + A: body carries sys_full_json_obj = {"config":{"autoLayout":true}} + B: control, same body without the layout field +Card body mirrors internal/channels/dingtalk/card.go buildCardBody() exactly. + +Credentials are read from the running container's environment and are never +printed. Only masked ids, HTTP statuses and DingTalk response bodies are shown. +""" +import json +import subprocess +import sys +import urllib.error +import urllib.request +import uuid + +CONTAINER = "dobotai-dingtalk" +TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken" +CREATE_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver" +STREAM_URL = "https://api.dingtalk.com/v1.0/card/streaming" +OWNER_ID = "243143382023108067" # 姚章浩 (channel_actors.dingtalk, test tenant) +WIDE = json.dumps({"config": {"autoLayout": True}}, separators=(",", ":")) + +CONTENT = ( + "**{tag}**\n\n" + "这是一条 PC 宽度对照测试卡片,由测试机器人在私聊里直接推送。\n\n" + "- 投放体里的布局字段:{field}\n" + "- 期望:{expect}\n" + "- 请在 PC 端对比两张卡片的宽度差异\n\n" + "移动端请忽略,宽屏只影响桌面端渲染。" +) + + +def env(key: str) -> str: + out = subprocess.run( + ["docker", "exec", CONTAINER, "printenv", key], + capture_output=True, text=True, check=True, + ).stdout.strip() + if not out: + sys.exit(f"missing env {key}") + return out + + +def call(method: str, url: str, payload: dict, token: str = "") -> tuple[int, str]: + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Content-Type", "application/json") + if token: + req.add_header("x-acs-dingtalk-access-token", token) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, resp.read(4096).decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read(4096).decode("utf-8", "replace") + + +def mask(v: str) -> str: + return v[:6] + "..." + v[-4:] if len(v) > 12 else "***" + + +def main() -> None: + app_key, app_secret = env("DINGTALK_CLIENT_ID"), env("DINGTALK_CLIENT_SECRET") + tpl = env("DINGTALK_CARD_TEMPLATE_ID") + print(f"app_key={mask(app_key)} template={mask(tpl)} target={mask(OWNER_ID)}") + + status, body = call("POST", TOKEN_URL, {"appKey": app_key, "appSecret": app_secret}) + token = json.loads(body).get("accessToken", "") + print(f"token: http={status} got={'yes' if token else 'NO'}") + if not token: + sys.exit("no token") + + for tag, with_wide in (("A · 已带 autoLayout=true", True), ("B · 对照组(无布局字段)", False)): + param_map = { + "content": "", + "feedbackSelected": "", + "feedbackReasonSelected": "", + "showFeedbackReasons": "false", + "feedbackMessage": "", + } + if with_wide: + param_map["sys_full_json_obj"] = WIDE + out_track_id = str(uuid.uuid4()) + card_body = { + "cardTemplateId": tpl, + "outTrackId": out_track_id, + "callbackType": "STREAM", + "cardData": {"cardParamMap": param_map}, + "userIdType": 1, + "openSpaceId": "dtv1.card//IM_ROBOT." + OWNER_ID, + "imRobotOpenSpaceModel": {"supportForward": True}, + "imRobotOpenDeliverModel": { + "robotCode": app_key, + "spaceType": "IM_ROBOT", + "extension": {}, + }, + } + st, rb = call("POST", CREATE_URL, card_body, token) + print(f"[{tag}] create http={st} resp={rb[:300]}") + + text = CONTENT.format( + tag=tag, + field=("sys_full_json_obj = " + WIDE) if with_wide else "(无)", + expect="PC 端铺满窗口(宽屏)" if with_wide else "PC 端窄卡片(默认宽度)", + ) + stream_body = { + "outTrackId": out_track_id, + "guid": str(uuid.uuid4()), + "key": "content", + "content": text, + "isFull": True, + "isFinalize": True, + "isError": False, + } + st2, rb2 = call("PUT", STREAM_URL, stream_body, token) + print(f"[{tag}] finalize http={st2} resp={rb2[:200]} outTrackId={mask(out_track_id)}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/deploy/list_tables.sql b/deploy/list_tables.sql new file mode 100644 index 0000000..4f06940 --- /dev/null +++ b/deploy/list_tables.sql @@ -0,0 +1,5 @@ +\pset pager off +SELECT table_schema || '.' || table_name AS t +FROM information_schema.tables +WHERE table_schema NOT IN ('pg_catalog', 'information_schema') +ORDER BY 1; \ No newline at end of file diff --git a/deploy/probe_actors.sql b/deploy/probe_actors.sql new file mode 100644 index 0000000..269ace2 --- /dev/null +++ b/deploy/probe_actors.sql @@ -0,0 +1,8 @@ +\pset pager off +\echo == columns == +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_schema = 'public' AND table_name = 'channel_actors' +ORDER BY ordinal_position; +\echo == rows == +SELECT * FROM channel_actors ORDER BY 1 LIMIT 6; \ No newline at end of file diff --git a/deploy/prod_switch_reasoning_widescreen.sh b/deploy/prod_switch_reasoning_widescreen.sh new file mode 100644 index 0000000..1794f3e --- /dev/null +++ b/deploy/prod_switch_reasoning_widescreen.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Prod-server switch to the combined release: +# reasoning-body boundary refresh + offset-0 body boundary + card structural diagnostics +# + wide-screen (autoLayout) card payload +# RUN ON THE PRODUCTION HOST (120.76.218.137) as root. Do not wrap in ssh. +# +# Usage: bash prod_switch_reasoning_widescreen.sh +# +# Safety: requires the new image locally; tags the running image as a rollback tag; +# backs up runtime.env with its original mode; refuses unless DOBOTAI_IMAGE appears +# exactly once before and once after the edit and the line count is unchanged; +# refuses unless the new binary carries every release literal. +# Prints only image refs / variable names / literal names - never env values. +set -euo pipefail + +NEW_IMAGE="${1:-}" +[ -n "$NEW_IMAGE" ] || { echo "ABORT: pass the new image ref as \$1" >&2; exit 1; } + +RUNTIME_ENV="/opt/dobotai/container/runtime.env" +BACKUP_DIR="/opt/dobotai/container/backups" +COMPOSE_SERVICE="dobotai-dingtalk" +RELEASE_SLUG="reasoning-widescreen" + +OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)" +OLD_SHA="$(echo "$OLD_IMAGE" | sed 's/.*://')" +ROLLBACK_TAG="dobotai-dingtalk:rollback-${OLD_SHA}-before-${RELEASE_SLUG}" +STAMP="$(date +%Y%m%dT%H%M%S)" +BACKUP="$BACKUP_DIR/runtime.env.before-${RELEASE_SLUG}.${STAMP}" + +echo "== preflight ==" +docker image inspect "$NEW_IMAGE" >/dev/null || { echo "ABORT: $NEW_IMAGE not present locally (load it first)" >&2; exit 1; } +[ "$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; } +for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do + if docker run --rm --entrypoint sh "$NEW_IMAGE" -c "grep -aqF '$lit' /usr/local/bin/dobotai"; then + echo "literal $lit: PRESENT" + else + echo "ABORT: $NEW_IMAGE lacks literal '$lit' (wrong artifact)" >&2 + exit 1 + fi +done +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" +ORIG_MODE="$(stat -c '%a' "$RUNTIME_ENV")" +install -m "$ORIG_MODE" "$RUNTIME_ENV" "$BACKUP" +[ "$ORIG_MODE" = "600" ] || chmod 0600 "$RUNTIME_ENV" +echo "backup=$BACKUP mode=$ORIG_MODE" + +echo "== rewrite DOBOTAI_IMAGE ==" +tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)" +chmod "$ORIG_MODE" "$tmp" +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 +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 "$COMPOSE_SERVICE" + +echo "== readback ==" +sleep 4 +curl -fsS -m 5 http://127.0.0.1:8090/healthz; echo +docker inspect "$COMPOSE_SERVICE" --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}' +for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do + if docker exec "$COMPOSE_SERVICE" grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then + echo "running literal $lit: PRESENT" + else + echo "running literal $lit: ABSENT" + fi +done +echo "== log scan (since 90s) ==" +docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | grep -icE 'panic|fatal|level=error| ERROR ' || echo "0 error/panic lines" +docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | tail -6 + +echo "== rollback (one command, run on this host) ==" +echo "sed -i 's|^DOBOTAI_IMAGE=.*|DOBOTAI_IMAGE=$OLD_IMAGE|' $RUNTIME_ENV && cd /opt/dobotai/container && docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate $COMPOSE_SERVICE" +echo "or restore: install -m $ORIG_MODE $BACKUP $RUNTIME_ENV (then the compose line above)" \ No newline at end of file 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/deploy/test_switch_card_widescreen.sh b/deploy/test_switch_card_widescreen.sh new file mode 100644 index 0000000..d7657a2 --- /dev/null +++ b/deploy/test_switch_card_widescreen.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Test-server switch to the wide-screen (autoLayout) card image. +# 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="dobotai-dingtalk:local-cardfix-ba090d17" +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-widescreen" +STAMP="$(date +%Y%m%dT%H%M%S)" +BACKUP_DIR="/opt/dobotai/container/backups" +BACKUP="$BACKUP_DIR/runtime.env.before-card-widescreen.$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 sys_full_json_obj autoLayout _CARD_DEBUG_TOOL_ENTRY; 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 -5 +echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)" \ No newline at end of file diff --git a/deploy/test_switch_image.sh b/deploy/test_switch_image.sh new file mode 100644 index 0000000..e1ce8de --- /dev/null +++ b/deploy/test_switch_image.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Generic test-server image switch: rollback tag first, 0600 runtime.env backup, +# post-edit verification (exactly 1 DOBOTAI_IMAGE line, unchanged line count), +# container recreate, then read-only readback incl. binary literal presence. +# Usage: bash test_switch_image.sh