diff --git a/A2UI.md b/A2UI.md new file mode 100644 index 0000000..86cf690 --- /dev/null +++ b/A2UI.md @@ -0,0 +1,67 @@ +# 发送 A2UI 卡片 + +本 SDK 参考 [dingtalk-aicard](https://github.com/DingTalk-Real-AI/dingtalk-aicard/tree/d2c2e7f05592a346e72486f0e41b744c1189f6fa) 的公开 DWS 接入方式,增加可选发送通道。A2UI 消息使用 `version: "v1.0"`;V0.8 是钉钉规范版本,不能替换消息版本。 + +## 发送身份与准备 + +`DwsA2UIClient`(Go 为 `DWSA2UIClient`)调用本机 DWS,用其登录账号和 Profile 发送。显式配置后才启用,发送身份与 Channel 的机器人应用 Token 独立。建议指定固定的 `corpId:userId` Profile,创建和更新始终使用同一个 Profile;适配器拒绝同时选择多个 Profile。 + +先单独安装并登录 [DingTalk Workspace CLI](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli),确认所用版本有以下两个命令: + +```bash +dws chat message send-a2ui-card --help +dws chat message update-a2ui-card --help +``` + +默认执行 `dws`,可配置命令路径、固定前缀参数和执行超时(默认 30 秒)。SDK 使用参数数组执行,始终不调用 shell。DWS 接收一个 `--content` 参数,本适配器将其限制为 UTF-8 64 KiB,回执限制为 8 MiB。 + +新增发送通道可以独立使用,也可以注入已有 Channel 的配置。自定义 A2UI 通道需实现同样的发送、更新接口;这为其他已验证的投递集成保留扩展点。 + +## 消息与生命周期 + +消息是非空数组,元素可为 JSON 对象或已经序列化的 JSON 字符串。适配器将其转换为 DWS 要求的字符串数组,保留消息顺序和业务数据。SDK 检查 JSON、`version`、单一操作和非空 `surfaceId`;组件属性、引用、初始数据与资源需按 dingtalk-aicard 校验。 + +仓库中的 `example/a2ui-card.json` 是完整创建示例,`example/a2ui-update.json` 是同一 Surface 的完成增量。可在 dingtalk-aicard 仓库内设置 Python 环境并验证创建示例: + +```bash +python3 skills/dingtalk-aicard/scripts/setup_env.py +.aicard-venv/bin/python skills/dingtalk-aicard/scripts/aicard_lint.py /path/to/sdk/example/a2ui-card.json --preflight new-card --format json +``` + +组件与事件定义以 [钉钉规范](https://github.com/DingTalk-Real-AI/dingtalk-aicard/tree/d2c2e7f05592a346e72486f0e41b744c1189f6fa/spec) 为准。 + +单聊必须提供 `openDingTalkId`,群聊提供 `openConversationId`,选择一个目标。不要把机器人 `staffId`、普通 `userId` 或 `senderId` 当作个人开放标识;适配器不会自动转换这些标识。 + +创建使用 `send-a2ui-card`,状态为 `PROCESSING`。发送结果保留完整 DWS 回执,并提取服务端 `bizId`。缺少它时返回更新警告;不能把请求侧 `bizCardId` 或 `openTaskId` 当作更新标识,也不要自动再创建一张卡片。 + +更新使用 `update-a2ui-card`,必须传原卡片 `bizId`、非空增量和 `flowStatus`。保持原 `surfaceId`、组件 ID 和用户表单数据,发送完整 A2UI 消息,不发送 JSON token 片段。静态卡片通过状态 `FINISH` 和非空、保留原状态的增量结束。 + +状态支持 `PROCESSING`、`INPUTTING`、`FINISH`、`EXECUTING`、`ERROR`、`ABORTED`、`TIMEOUT`、`CONFIRMING`、`CONFIRMED`,兼容字符串 `1` 至 `9`。 + +## 验证与回调 + +只有回执明确确认接受请求才返回成功;执行错误、非 JSON 输出、无确认标志、dry-run 及失败回执会返回错误。写入超时或进程失败后,服务端可能已经接收请求;SDK 明确报告结果可能未知,并且不会自动重试或降级为 Markdown 消息。请保留回执并核实实际状态。 + +DWS 命令成功和本地测试不能证明钉钉客户端渲染或用户点击回调。本次适配器负责发送与更新;DWS 身份下的业务回调须通过相应的 `user_card_action_triggered` 事件通道另外接入,不会自动进入 Channel 的机器人 `cardAction` 回调。 + +参考仓库没有给出可复用的机器人应用 Token A2UI OpenAPI 实现。本适配器实现它已经公开的 DWS 路径;需要机器人原生发送时,应提供已验证的接口契约并实现可注入发送通道。 + +## SDK 调用 + +```java +import com.dingtalk.channel.*; +import java.util.Collections; + +DwsA2UIClient a2ui = new DwsA2UIClient( + Collections.singletonList("dws"), System.getenv("DWS_PROFILE"), 30000); +DingTalkChannel ch = DingTalkChannel.create(Config.builder( + System.getenv("DD_CLIENT_ID"), System.getenv("DD_CLIENT_SECRET")) + .a2uiClient(a2ui).build()); +// messages、delta 分别是创建和完成增量的 List 或 List。 +A2UICardResult result = ch.sendA2UICard( + A2UITarget.forUser(System.getenv("DWS_OPEN_DINGTALK_ID")), messages); +if (result.bizId == null) throw new IllegalStateException(result.updateWarning); +ch.updateA2UICard(result.bizId, delta, "FINISH"); +``` + +群聊使用 `A2UITarget.forGroup("")`。独立使用时直接调用 `DwsA2UIClient.sendCard` 和 `updateCard`,不需要机器人凭据。完整可运行示例为 `com.dingtalk.channel.example.A2UI`。 diff --git a/README.md b/README.md index e87dfd2..4881b61 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,8 @@ DD_CLIENT_ID=... DD_CLIENT_SECRET=... \ ## License MIT + + +## A2UI 卡片 + +支持通过显式配置的 DWS 通道发送和更新 A2UI 卡片,使用 DWS 登录身份。接口、完整示例、消息校验和回调范围见 [A2UI 接入说明](A2UI.md)。 diff --git a/README.zh-CN.md b/README.zh-CN.md index 2704bc1..7304e4e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -104,3 +104,8 @@ DD_CLIENT_ID=... DD_CLIENT_SECRET=... \ ## License MIT + + +## A2UI 卡片 + +支持通过显式配置的 DWS 通道发送和更新 A2UI 卡片,使用 DWS 登录身份。接口、完整示例、消息校验和回调范围见 [A2UI 接入说明](A2UI.md)。 diff --git a/example/a2ui-card.json b/example/a2ui-card.json new file mode 100644 index 0000000..a8e8000 --- /dev/null +++ b/example/a2ui-card.json @@ -0,0 +1,41 @@ +[ + { + "version": "v1.0", + "createSurface": { + "surfaceId": "channel-sdk-example", + "catalogId": "https://dingtalk.com/card/a2ui/catalogs/public/catalog.json" + } + }, + { + "version": "v1.0", + "updateDataModel": { + "surfaceId": "channel-sdk-example", + "path": "/", + "value": { + "text": "这是 Channel SDK 的 A2UI 示例。" + } + } + }, + { + "version": "v1.0", + "updateComponents": { + "surfaceId": "channel-sdk-example", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "body" + ] + }, + { + "id": "body", + "component": "Markdown", + "content": { + "path": "/text" + } + } + ] + } + } +] diff --git a/example/a2ui-update.json b/example/a2ui-update.json new file mode 100644 index 0000000..9a6c2db --- /dev/null +++ b/example/a2ui-update.json @@ -0,0 +1,10 @@ +[ + { + "version": "v1.0", + "updateDataModel": { + "surfaceId": "channel-sdk-example", + "path": "/text", + "value": "示例已完成;这条消息更新原卡片的数据。" + } + } +] diff --git a/src/main/java/com/dingtalk/channel/A2UICardResult.java b/src/main/java/com/dingtalk/channel/A2UICardResult.java new file mode 100644 index 0000000..67d7058 --- /dev/null +++ b/src/main/java/com/dingtalk/channel/A2UICardResult.java @@ -0,0 +1,17 @@ +package com.dingtalk.channel; + +import com.google.gson.JsonObject; + +/** 完整回执与服务端 bizId;缺少 bizId 时保留回执,不自动重发。 */ +public final class A2UICardResult { + public final String bizId; + public final JsonObject receipt; + public final String updateWarning; + + A2UICardResult(String bizId, JsonObject receipt) { + this.bizId = bizId; + this.receipt = receipt; + this.updateWarning = bizId == null + ? "回执未包含可用的 bizId;请保留回执并核实服务端标识,不要自动重发创建请求。" : null; + } +} diff --git a/src/main/java/com/dingtalk/channel/A2UIClient.java b/src/main/java/com/dingtalk/channel/A2UIClient.java new file mode 100644 index 0000000..c9bedde --- /dev/null +++ b/src/main/java/com/dingtalk/channel/A2UIClient.java @@ -0,0 +1,11 @@ +package com.dingtalk.channel; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.util.List; + +/** 可注入 A2UI 发送通道;消息接受对象或 JSON 字符串的非空列表。 */ +public interface A2UIClient { + A2UICardResult sendCard(A2UITarget target, List messages) throws IOException, InterruptedException; + JsonObject updateCard(String bizId, List messages, String flowStatus) throws IOException, InterruptedException; +} diff --git a/src/main/java/com/dingtalk/channel/A2UITarget.java b/src/main/java/com/dingtalk/channel/A2UITarget.java new file mode 100644 index 0000000..d03462c --- /dev/null +++ b/src/main/java/com/dingtalk/channel/A2UITarget.java @@ -0,0 +1,20 @@ +package com.dingtalk.channel; + +/** A2UI 个人 openDingTalkId 或群 openConversationId,与机器人 staffId 区分。 */ +public final class A2UITarget { + final String flag; + final String id; + + private A2UITarget(String flag, String id) { + this.flag = flag; + this.id = DwsA2UIClient.identifier(id, flag); + } + + public static A2UITarget forUser(String openDingTalkId) { + return new A2UITarget("open-dingtalk-id", openDingTalkId); + } + + public static A2UITarget forGroup(String conversationId) { + return new A2UITarget("conversation-id", conversationId); + } +} diff --git a/src/main/java/com/dingtalk/channel/Config.java b/src/main/java/com/dingtalk/channel/Config.java index 714872b..8375eb2 100644 --- a/src/main/java/com/dingtalk/channel/Config.java +++ b/src/main/java/com/dingtalk/channel/Config.java @@ -29,6 +29,8 @@ public class Config { final String apiBase; final String oapiBase; final String cardTemplateId; + /** 显式配置的 A2UI 发送通道;默认不启用。 */ + final A2UIClient a2uiClient; final long streamThrottleMs; final long cardWatchdogMs; final long errorCooldownMs; @@ -55,6 +57,7 @@ public class Config { private Config(Builder b) { this.clientId = b.clientId; this.clientSecret = b.clientSecret; + this.a2uiClient = b.a2uiClient; this.apiBase = b.apiBase == null || b.apiBase.isEmpty() ? DEFAULT_API_BASE : b.apiBase; this.oapiBase = b.oapiBase == null || b.oapiBase.isEmpty() ? DEFAULT_OAPI_BASE : b.oapiBase; this.cardTemplateId = b.cardTemplateId == null || b.cardTemplateId.isEmpty() @@ -96,6 +99,7 @@ public static class Builder { String apiBase; String oapiBase; String cardTemplateId; + A2UIClient a2uiClient; long streamThrottleMs; long cardWatchdogMs = -1; long errorCooldownMs = -1; @@ -121,6 +125,7 @@ public static class Builder { public Builder apiBase(String v) { this.apiBase = v; return this; } public Builder oapiBase(String v) { this.oapiBase = v; return this; } public Builder cardTemplateId(String v) { this.cardTemplateId = v; return this; } + public Builder a2uiClient(A2UIClient v) { this.a2uiClient = v; return this; } public Builder streamThrottleMs(long v) { this.streamThrottleMs = v; return this; } public Builder cardWatchdogMs(long v) { this.cardWatchdogMs = v; return this; } public Builder errorCooldownMs(long v) { this.errorCooldownMs = v; return this; } diff --git a/src/main/java/com/dingtalk/channel/DingTalkChannel.java b/src/main/java/com/dingtalk/channel/DingTalkChannel.java index 5ec3450..fa3d731 100644 --- a/src/main/java/com/dingtalk/channel/DingTalkChannel.java +++ b/src/main/java/com/dingtalk/channel/DingTalkChannel.java @@ -330,6 +330,20 @@ public void sendMarkdown(SendTarget target, String title, String text) throws In sender.sendMarkdown(target, title, text); } + /** 通过显式配置的 A2UI 通道发送,返回 bizId 与完整回执。 */ + public A2UICardResult sendA2UICard(A2UITarget target, java.util.List messages) + throws java.io.IOException, InterruptedException { + if (cfg.a2uiClient == null) throw new IllegalStateException("请先配置 a2uiClient(例如 DwsA2UIClient)"); + return cfg.a2uiClient.sendCard(target, messages); + } + + /** 用服务端 bizId 更新同一卡片,flowStatus 必填。 */ + public com.google.gson.JsonObject updateA2UICard(String bizId, java.util.List messages, String flowStatus) + throws java.io.IOException, InterruptedException { + if (cfg.a2uiClient == null) throw new IllegalStateException("请先配置 a2uiClient(例如 DwsA2UIClient)"); + return cfg.a2uiClient.updateCard(bizId, messages, flowStatus); + } + public void sendImage(SendTarget target, String imageUrl) throws InterruptedException { sender.sendImage(target, imageUrl); } diff --git a/src/main/java/com/dingtalk/channel/DwsA2UIClient.java b/src/main/java/com/dingtalk/channel/DwsA2UIClient.java new file mode 100644 index 0000000..2fa3df1 --- /dev/null +++ b/src/main/java/com/dingtalk/channel/DwsA2UIClient.java @@ -0,0 +1,229 @@ +package com.dingtalk.channel; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.Strictness; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; + +/** + * 按 dingtalk-aicard 的公开 DWS 接入方式发送 A2UI;DWS 需单独安装、登录。 + * profile 决定发送身份,与机器人应用 Token 独立。始终使用 argv,不调用 shell。 + */ +public final class DwsA2UIClient implements A2UIClient { + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + public static final List FLOW_STATUSES = Collections.unmodifiableList(Arrays.asList( + "PROCESSING", "INPUTTING", "FINISH", "EXECUTING", "ERROR", + "ABORTED", "TIMEOUT", "CONFIRMING", "CONFIRMED")); + private static final List OPERATIONS = Arrays.asList( + "createSurface", "updateComponents", "updateDataModel", "deleteSurface"); + private final List command; + private final String profile; + private final long timeoutMs; + + public DwsA2UIClient() { + this(Collections.singletonList("dws"), null, 30000); + } + + public DwsA2UIClient(List command, String profile, long timeoutMs) { + if (command == null || command.isEmpty()) throw new IllegalArgumentException("command 必须是非空字符串数组"); + for (String part : command) { + if (part == null || part.isEmpty() || part.indexOf('\0') >= 0) { + throw new IllegalArgumentException("command 必须是非空字符串数组"); + } + } + if (timeoutMs <= 0) throw new IllegalArgumentException("timeoutMs 必须大于 0"); + this.command = new ArrayList<>(command); + this.profile = profile == null ? null : identifier(profile, "profile"); + if (this.profile != null && this.profile.contains(",")) throw new IllegalArgumentException("A2UI 发送只允许一个 DWS Profile"); + this.timeoutMs = timeoutMs; + } + + static String identifier(String value, String name) { + if (value == null || value.trim().isEmpty()) throw new IllegalArgumentException(name + " 必须是非空标识"); + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) < 32 || value.charAt(i) == 127) throw new IllegalArgumentException(name + " 必须是非空标识"); + } + return value.trim(); + } + + private static JsonElement parse(String json) { + try { + JsonReader reader = new JsonReader(new StringReader(json)); + reader.setStrictness(Strictness.STRICT); + JsonElement value = JsonParser.parseReader(reader); + if (reader.peek() != JsonToken.END_DOCUMENT) throw new IllegalArgumentException("JSON 尾部存在多余内容"); + return value; + } catch (IOException | RuntimeException error) { + throw new IllegalArgumentException("不是有效 JSON"); + } + } + + /** 只检查消息信封;组件和完整创建状态使用 dingtalk-aicard 校验。 */ + public static String serializeMessages(List messages) { + if (messages == null || messages.isEmpty()) throw new IllegalArgumentException("A2UI 消息必须是非空数组"); + List strings = new ArrayList<>(); + for (Object message : messages) { + String encoded; + JsonElement value; + try { + encoded = message instanceof String ? (String) message : GSON.toJson(message); + value = parse(encoded); + } catch (RuntimeException error) { + throw new IllegalArgumentException("A2UI 消息不是有效 JSON"); + } + if (!value.isJsonObject()) throw new IllegalArgumentException("A2UI 消息必须是 version=v1.0 的对象"); + JsonObject object = value.getAsJsonObject(); + if (!object.has("version") || !object.get("version").isJsonPrimitive() + || !object.get("version").getAsJsonPrimitive().isString() || !"v1.0".equals(object.get("version").getAsString())) { + throw new IllegalArgumentException("A2UI 消息必须是 version=v1.0 的对象"); + } + int count = 0; + for (String operation : OPERATIONS) { + if (object.has(operation)) { + count++; + JsonElement op = object.get(operation); + if (!op.isJsonObject()) throw new IllegalArgumentException("A2UI 操作必须是 JSON 对象"); + JsonElement surface = op.getAsJsonObject().get("surfaceId"); + if (surface == null || !surface.isJsonPrimitive() || !surface.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("surfaceId 必须是非空标识"); + } + identifier(surface.getAsString(), "surfaceId"); + } + } + if (count != 1) throw new IllegalArgumentException("A2UI 消息必须包含一个操作"); + strings.add(encoded); + } + String content = GSON.toJson(strings); + if (content.getBytes(StandardCharsets.UTF_8).length > 65536) throw new IllegalArgumentException("DWS A2UI content 超过 64 KiB"); + return content; + } + + private static List envelopeChain(JsonObject receipt) throws IOException { + List chain = new ArrayList<>(); + JsonObject value = receipt; + for (int i = 0; i < 5 && value != null; i++) { + chain.add(value); + if (isBoolean(value, "success", false) || isBoolean(value, "ok", false) || isBoolean(value, "isError", true) + || isBoolean(value, "dry_run", true) || isBoolean(value, "dryRun", true) + || (value.has("error") && !value.get("error").isJsonNull())) throw new IOException("DWS 返回失败回执"); + JsonElement outcome = value.get("outcome"); + if (outcome != null && (!outcome.isJsonPrimitive() || !outcome.getAsJsonPrimitive().isString() + || (!"success".equals(outcome.getAsString()) && !"pending".equals(outcome.getAsString())))) throw new IOException("DWS 返回失败回执"); + JsonElement next = value.get("data"); + if (next == null || next.isJsonNull()) next = value.get("result"); + value = next != null && next.isJsonObject() ? next.getAsJsonObject() : null; + } + boolean accepted = false; + for (JsonObject item : chain) accepted |= isBoolean(item, "success", true) || isBoolean(item, "ok", true); + if (!accepted) throw new IOException("DWS 回执未明确确认接受请求;发送结果可能未知,请核实后再重试"); + return chain; + } + + private static boolean isBoolean(JsonObject value, String name, boolean expected) { + JsonElement item = value.get(name); + return item != null && item.isJsonPrimitive() && item.getAsJsonPrimitive().isBoolean() && item.getAsBoolean() == expected; + } + + private JsonObject invoke(String... args) throws IOException, InterruptedException { + List argv = new ArrayList<>(command); + argv.addAll(Arrays.asList("chat", "message")); + argv.addAll(Arrays.asList(args)); + argv.addAll(Arrays.asList("--format=json", "--yes")); + if (profile != null) argv.add("--profile=" + profile); + Process process; + try { + process = new ProcessBuilder(argv).start(); + } catch (IOException error) { + throw new IOException("DWS 无法启动;请检查安装和 command 配置"); + } + FutureTask output = new FutureTask<>(() -> { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int count; + while ((count = process.getInputStream().read(buffer)) != -1) { + if (bytes.size() + count > 8 * 1024 * 1024) throw new IOException("DWS 回执超过 8 MiB"); + bytes.write(buffer, 0, count); + } + return bytes.toByteArray(); + }); + Thread reader = new Thread(output, "dws-a2ui-output"); + reader.setDaemon(true); + reader.start(); + Thread errors = new Thread(() -> { + // 丢弃 stderr,避免错误文本带出命令行、业务数据或凭据。 + try (InputStream input = process.getErrorStream()) { + byte[] buffer = new byte[4096]; + while (input.read(buffer) != -1) { } + } catch (IOException ignored) { } + }, "dws-a2ui-errors"); + errors.setDaemon(true); + errors.start(); + try { + process.getOutputStream().close(); + if (!process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { + throw new IOException("DWS 执行超时;发送结果可能未知,请核实后再重试"); + } + if (process.exitValue() != 0) throw new IOException("DWS 执行失败;发送结果可能未知,请核实后再重试"); + JsonElement value; + try { + value = parse(new String(output.get(1, TimeUnit.SECONDS), StandardCharsets.UTF_8)); + } catch (ExecutionException | java.util.concurrent.TimeoutException | IllegalArgumentException error) { + throw new IOException("DWS 输出不是有效 JSON;发送结果可能未知,请保留现场核实"); + } + if (!value.isJsonObject()) throw new IOException("DWS 回执必须是 JSON 对象"); + JsonObject receipt = value.getAsJsonObject(); + envelopeChain(receipt); + return receipt; + } finally { + if (process.isAlive()) process.destroyForcibly(); + process.getInputStream().close(); + process.getErrorStream().close(); + reader.interrupt(); + errors.interrupt(); + } + } + + @Override + public A2UICardResult sendCard(A2UITarget target, List messages) throws IOException, InterruptedException { + if (target == null) throw new IllegalArgumentException("A2UI target 必须恰好选择一个接收目标"); + String content = serializeMessages(messages); + JsonObject receipt = invoke("send-a2ui-card", "--" + target.flag + "=" + target.id, "--content=" + content); + List chain = envelopeChain(receipt); + String bizId = null; + for (int i = chain.size() - 1; i >= 0; i--) { + JsonElement value = chain.get(i).get("bizId"); + if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + try { bizId = identifier(value.getAsString(), "bizId"); break; } + catch (IllegalArgumentException ignored) { } + } + } + return new A2UICardResult(bizId, receipt); + } + + @Override + public JsonObject updateCard(String bizId, List messages, String flowStatus) throws IOException, InterruptedException { + String id = identifier(bizId, "bizId"); + String status = flowStatus == null ? "" : flowStatus.trim().toUpperCase(Locale.ROOT); + if (status.matches("[1-9]")) status = FLOW_STATUSES.get(Integer.parseInt(status) - 1); + if (!FLOW_STATUSES.contains(status)) throw new IllegalArgumentException("不支持的 A2UI flowStatus"); + String content = serializeMessages(messages); + return invoke("update-a2ui-card", "--biz-id=" + id, "--content=" + content, "--flow-status=" + status); + } +} diff --git a/src/main/java/com/dingtalk/channel/example/A2UI.java b/src/main/java/com/dingtalk/channel/example/A2UI.java new file mode 100644 index 0000000..b9d401c --- /dev/null +++ b/src/main/java/com/dingtalk/channel/example/A2UI.java @@ -0,0 +1,39 @@ +package com.dingtalk.channel.example; + +import com.dingtalk.channel.A2UICardResult; +import com.dingtalk.channel.A2UITarget; +import com.dingtalk.channel.DwsA2UIClient; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 使用 DWS 的指定 Profile 发送示例卡片并完成原卡片,不需要机器人凭据。 */ +public class A2UI { + private static List read(String file) throws Exception { + List messages = new ArrayList<>(); + try (Reader reader = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) { + for (JsonElement value : JsonParser.parseReader(reader).getAsJsonArray()) messages.add(value); + } + return messages; + } + + public static void main(String[] args) throws Exception { + String profile = System.getenv("DWS_PROFILE"), recipient = System.getenv("DWS_OPEN_DINGTALK_ID"); + if (profile == null || profile.isEmpty() || recipient == null || recipient.isEmpty()) { + throw new IllegalArgumentException("请设置 DWS_PROFILE 和 DWS_OPEN_DINGTALK_ID"); + } + String executable = System.getenv("DWS_BIN"); + if (executable == null || executable.isEmpty()) executable = "dws"; + DwsA2UIClient client = new DwsA2UIClient(Collections.singletonList(executable), profile, 30000); + A2UICardResult result = client.sendCard(A2UITarget.forUser(recipient), read("example/a2ui-card.json")); + if (result.bizId == null) throw new IllegalStateException(result.updateWarning); + client.updateCard(result.bizId, read("example/a2ui-update.json"), "FINISH"); + System.out.println("示例卡片已完成,bizId=" + result.bizId); + } +} diff --git a/src/test/java/com/dingtalk/channel/A2UITest.java b/src/test/java/com/dingtalk/channel/A2UITest.java new file mode 100644 index 0000000..7771fcd --- /dev/null +++ b/src/test/java/com/dingtalk/channel/A2UITest.java @@ -0,0 +1,152 @@ +package com.dingtalk.channel; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.After; +import org.junit.Test; +import static org.junit.Assert.*; + +public class A2UITest { + private static final String MESSAGE = "{\"version\":\"v1.0\",\"updateDataModel\":{\"surfaceId\":\"sdk-card\",\"path\":\"/text\",\"value\":\"中文、引号\\\"与 $(echo test) `test`\"}}"; + private final List directories = new ArrayList<>(); + + /** 模拟 DWS 可执行进程,只记录 argv,不调用真实服务。 */ + public static class FakeDws { + public static void main(String[] args) throws Exception { + Path trace = Paths.get(args[0]); + String response = args[1], mode = args[2]; + String line = new Gson().toJson(Arrays.asList(args).subList(3, args.length)) + "\n"; + Files.write(trace, line.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); + if ("hang".equals(mode)) Thread.sleep(10000); + if ("fail".equals(mode)) { System.err.print("不应暴露的凭据占位符"); System.exit(2); } + System.out.print(response); + } + } + + private static class Fixture { + DwsA2UIClient client; + Path trace; + + List calls() throws IOException { + List result = new ArrayList<>(); + for (String line : Files.readAllLines(trace, StandardCharsets.UTF_8)) result.add(JsonParser.parseString(line).getAsJsonArray()); + return result; + } + } + + private Fixture fixture(String response, String mode, long timeoutMs) throws IOException { + Path dir = Files.createTempDirectory(Paths.get("."), ".a2ui-test-"); + directories.add(dir); + Fixture result = new Fixture(); + result.trace = dir.resolve("模拟 dws 回执.jsonl"); + result.client = new DwsA2UIClient(Arrays.asList( + Paths.get(System.getProperty("java.home"), "bin", "java").toString(), "-cp", System.getProperty("java.class.path"), + FakeDws.class.getName(), result.trace.toString(), response, mode), "corp:user", timeoutMs); + return result; + } + + @After + public void cleanup() throws IOException { + for (Path dir : directories) { + try (java.util.stream.Stream files = Files.walk(dir)) { + files.sorted(Collections.reverseOrder()).forEach(file -> { try { Files.delete(file); } catch (IOException ignored) { } }); + } + } + } + + @Test + public void 消息对象和字符串均保留语义() { + JsonObject object = JsonParser.parseString(MESSAGE).getAsJsonObject(); + JsonArray encoded = JsonParser.parseString(DwsA2UIClient.serializeMessages(Arrays.asList(object, MESSAGE))).getAsJsonArray(); + assertEquals(object, JsonParser.parseString(encoded.get(0).getAsString())); + assertEquals(MESSAGE, encoded.get(1).getAsString()); + for (List messages : Arrays.asList(Collections.emptyList(), Collections.singletonList("not-json"), Collections.singletonList(null), + Collections.singletonList("{\"version\":\"v0.8\"}"), Collections.singletonList("{\"version\":\"v1.0\"}"), + Collections.singletonList("{\"version\":\"v1.0\",\"deleteSurface\":{\"surfaceId\":\"\"}}"))) { + assertThrows(IllegalArgumentException.class, () -> DwsA2UIClient.serializeMessages(messages)); + } + object.getAsJsonObject("updateDataModel").addProperty("value", String.join("", Collections.nCopies(24000, "中"))); + assertThrows(IllegalArgumentException.class, () -> DwsA2UIClient.serializeMessages(Collections.singletonList(object))); + } + + @Test + public void 显式通道发送单聊群聊并完成原卡片() throws Exception { + Fixture fixture = fixture("{\"ok\":true,\"outcome\":\"success\",\"data\":{\"success\":true,\"result\":{\"bizId\":\"server-biz\"}}}", "ok", 5000); + DingTalkChannel ch = DingTalkChannel.create(Config.builder("unused", "unused").a2uiClient(fixture.client).build()); + A2UICardResult result = ch.sendA2UICard(A2UITarget.forUser("D-user"), Collections.singletonList(MESSAGE)); + assertEquals("server-biz", result.bizId); + assertNull(result.updateWarning); + assertEquals("server-biz", result.receipt.getAsJsonObject("data").getAsJsonObject("result").get("bizId").getAsString()); + ch.sendA2UICard(A2UITarget.forGroup("--group-value"), Collections.singletonList(MESSAGE)); + ch.updateA2UICard(result.bizId, Collections.singletonList(MESSAGE), "3"); + List calls = fixture.calls(); + assertEquals(3, calls.size()); + assertTrue(calls.get(0).toString().contains("--open-dingtalk-id=D-user")); + assertTrue(calls.get(1).toString().contains("--conversation-id=--group-value")); + assertTrue(calls.get(2).toString().contains("--biz-id=server-biz")); + assertTrue(calls.get(2).toString().contains("--flow-status=FINISH")); + for (JsonArray args : calls) { + assertTrue(args.toString().contains("--profile=corp:user")); + assertTrue(args.toString().contains("--format=json")); + assertTrue(args.toString().contains("--yes")); + assertFalse(args.toString().contains("client-secret")); + for (com.google.gson.JsonElement arg : args) { + if (arg.getAsString().startsWith("--content=")) { + JsonArray messages = JsonParser.parseString(arg.getAsString().substring("--content=".length())).getAsJsonArray(); + assertEquals(JsonParser.parseString(MESSAGE), JsonParser.parseString(messages.get(0).getAsString())); + } + } + } + } + + @Test + public void 未启用或参数错误不执行子进程() throws Exception { + assertThrows(IllegalArgumentException.class, () -> new DwsA2UIClient(Collections.singletonList("dws"), "corp:user,other:user", 5000)); + DingTalkChannel ch = DingTalkChannel.create(Config.builder("unused", "unused").build()); + assertThrows(IllegalStateException.class, () -> ch.sendA2UICard(A2UITarget.forGroup("cid"), Collections.singletonList(MESSAGE))); + assertThrows(IllegalStateException.class, () -> ch.updateA2UICard("biz", Collections.singletonList(MESSAGE), "FINISH")); + DwsA2UIClient client = new DwsA2UIClient(Collections.singletonList("不存在的 dws"), null, 5000); + assertThrows(IllegalArgumentException.class, () -> A2UITarget.forUser(" ")); + assertThrows(IllegalArgumentException.class, () -> client.sendCard(null, Collections.singletonList(MESSAGE))); + assertThrows(IllegalArgumentException.class, () -> client.updateCard("", Collections.singletonList(MESSAGE), "FINISH")); + assertThrows(IllegalArgumentException.class, () -> client.updateCard("biz", Collections.singletonList(MESSAGE), "unknown")); + } + + @Test + public void 请求侧bizCardId不作为更新标识且不重发() throws Exception { + Fixture fixture = fixture("{\"success\":true,\"result\":{\"bizCardId\":\"request-only\",\"openTaskId\":\"task\"}}", "ok", 5000); + A2UICardResult result = fixture.client.sendCard(A2UITarget.forGroup("cid"), Collections.singletonList(MESSAGE)); + assertNull(result.bizId); + assertTrue(result.updateWarning.contains("不要自动重发")); + assertEquals(1, fixture.calls().size()); + } + + @Test + public void 业务失败进程失败和非JSON回执不被接受() throws Exception { + for (String[] item : new String[][]{{"{\"success\":false}", "ok"}, {"{\"success\":true,\"result\":{\"success\":false}}", "ok"}, {"not-json", "ok"}, {"{}", "ok"}, {"{\"result\":{\"bizId\":\"unconfirmed\"}}", "ok"}, {"{\"ok\":true,\"outcome\":\"success\",\"dry_run\":true}", "ok"}, {"{}", "fail"}}) { + Fixture fixture = fixture(item[0], item[1], 5000); + IOException error = assertThrows(IOException.class, () -> fixture.client.sendCard(A2UITarget.forGroup("cid"), Collections.singletonList(MESSAGE))); + assertFalse(error.getMessage().contains("不应暴露")); + assertEquals(1, fixture.calls().size()); + } + } + + @Test + public void 超时明确报告未知结果() throws Exception { + Fixture fixture = fixture("{}", "hang", 200); + IOException error = assertThrows(IOException.class, () -> fixture.client.sendCard(A2UITarget.forGroup("cid"), Collections.singletonList(MESSAGE))); + assertTrue(error.getMessage().contains("结果可能未知")); + } +}