fix(dingtalk): 投放 AI 卡片时开启宽屏自适应布局

PC 端卡片宽度过窄:创建卡片(createAndDeliver)请求体从未请求宽屏布局。

修复落在创建卡片的 JSON 载荷层 —— cardData.cardParamMap 的钉钉保留系统
字段 sys_full_json_obj 内传 {"config":{"autoLayout":true}}。config 为
对象类型,钉钉规定复杂类型变量必须走该保留字段;放在 cardParamMap 兄弟
节点会因变量未赋值而静默失效(宽度不变、无报错)。

- 仅影响 PC 宽屏渲染;移动端保持自适应布局不变。
- 未改动富文本组件宽度/padding,也未改动模板内静态内容。
- 流式更新(streaming)请求体不带该字段,避免中途变更布局。

新增回归守卫测试:投放体(direct/group)必须含该字段且 autoLayout=true,
流式更新体必须不含;防止退化为静默失效写法。

验证:gofmt 干净、go vet ok、go test ./... 全绿;
A/B 探针(A 带字段 / B 对照)经 PC 端人工确认 A 卡片变宽。
This commit is contained in:
hermes-agent
2026-09-17 09:59:39 +08:00
parent ba090d1705
commit a096ba5f51
6 changed files with 331 additions and 0 deletions
+121
View File
@@ -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()
+5
View File
@@ -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;
+8
View File
@@ -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;
+67
View File
@@ -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)"
+19
View File
@@ -21,6 +21,24 @@ const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
// cardWideScreenJSONKey is DingTalk's reserved system field inside
// cardData.cardParamMap. Template variables whose value is a complex type
// (object / array of objects) must be passed through this field as a JSON
// string; plain string/number/boolean variables go in cardParamMap
// directly. Putting a complex value anywhere else (for example as a
// sibling of cardParamMap under cardData) leaves the template variable
// unassigned and silently falls back to the default layout.
cardWideScreenJSONKey = "sys_full_json_obj"
// cardWideScreenJSON configures the AI card's root "config": PC clients
// render the card wide (auto layout) when autoLayout is true. The card
// template must declare an object variable "config" with a boolean child
// "autoLayout" (card builder > 数据源 > 编辑变量) and the value must be
// passed when the card is created - setting it statically inside the
// template does not take effect. Mobile clients keep their own adaptive
// layout, so this only widens the card on desktop.
cardWideScreenJSON = `{"config":{"autoLayout":true}}`
)
// CardRequest is the input for sending a final AI card.
@@ -229,6 +247,7 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
cardWideScreenJSONKey: cardWideScreenJSON,
},
},
"userIdType": 1,
+111
View File
@@ -1289,6 +1289,117 @@ func TestOAuthAcrossStartAndUpdates(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: PC wide-screen layout config is delivered through the reserved
// cardParamMap system field, never as a sibling of cardParamMap
// ---------------------------------------------------------------------------
func TestCardWideScreenAutoLayoutParam(t *testing.T) {
cases := []struct {
name string
req CardRequest
}{
{
name: "direct",
req: CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
{
name: "group",
req: CardRequest{
ConversationType: core.ConversationGroup,
ExternalChatID: "chat-789",
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
if _, err := sender.StartCard(context.Background(), tc.req); err != nil {
t.Fatalf("StartCard: %v", err)
}
cardData, ok := ts.lastCreateBody["cardData"].(map[string]any)
if !ok {
t.Fatalf("cardData missing or wrong type: %#v", ts.lastCreateBody["cardData"])
}
// Regression guard: a complex-typed value placed next to
// cardParamMap leaves the template variable unassigned and the
// card silently renders in the default narrow layout.
for _, badKey := range []string{"config", cardWideScreenJSONKey} {
if _, present := cardData[badKey]; present {
t.Fatalf("%q must not be a sibling of cardParamMap: %#v", badKey, cardData)
}
}
paramMap, ok := cardData["cardParamMap"].(map[string]any)
if !ok {
t.Fatalf("cardParamMap missing or wrong type: %#v", cardData["cardParamMap"])
}
raw, ok := paramMap[cardWideScreenJSONKey].(string)
if !ok {
t.Fatalf("%s must be a JSON string, got %#v", cardWideScreenJSONKey, paramMap[cardWideScreenJSONKey])
}
var decoded struct {
Config struct {
AutoLayout bool `json:"autoLayout"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("%s is not valid JSON (%q): %v", cardWideScreenJSONKey, raw, err)
}
if !decoded.Config.AutoLayout {
t.Fatalf("config.autoLayout=false in %q, want true", raw)
}
// Plain string variables must keep going through cardParamMap directly.
if paramMap["content"] != tc.req.Content {
t.Errorf("content=%v, want %v", paramMap["content"], tc.req.Content)
}
})
}
}
// ---------------------------------------------------------------------------
// Test: streaming updates only carry content (no layout config)
// ---------------------------------------------------------------------------
func TestCardWideScreenNotSentOnStreamingUpdate(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
outTrackID, err := sender.StartCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "placeholder",
})
if err != nil {
t.Fatalf("StartCard: %v", err)
}
if err := sender.UpdateCard(context.Background(), outTrackID, "streamed chunk", false, false); err != nil {
t.Fatalf("UpdateCard: %v", err)
}
if _, present := ts.lastStreamBody[cardWideScreenJSONKey]; present {
t.Fatalf("streaming body must not carry %s: %#v", cardWideScreenJSONKey, ts.lastStreamBody)
}
if ts.lastStreamBody["key"] != "content" {
t.Errorf("stream key=%v, want content", ts.lastStreamBody["key"])
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------