a096ba5f51
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 卡片变宽。
121 lines
4.4 KiB
Python
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() |