Files
dobotai-platform/deploy/card_widescreen_probe.py
hermes-agent a096ba5f51 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 卡片变宽。
2026-09-17 09:59:39 +08:00

121 lines
4.4 KiB
Python

#!/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()