diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index a2b097b2..5c0d151e 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -61,6 +61,10 @@ export default defineConfig({ text: '用户属性扩展', link: '/advance/user-property' }, + { + text: '@ 人', + link: '/advance/at-mention' + }, { text: 'Markdown 语法', link: '/advance/markdown' diff --git a/docs/advance/at-mention.md b/docs/advance/at-mention.md new file mode 100644 index 00000000..48cfa206 --- /dev/null +++ b/docs/advance/at-mention.md @@ -0,0 +1,86 @@ +# @ 人 + +## @ 是怎么生效的 + +请求里的 `at` 对象带上被 @ 人的手机号(或 `isAtAll`)——它决定**谁收到通知**。 + +`MARKDOWN` 和 `ACTION_CARD` 除此之外还要求**消息正文里出现对应的 `@手机号` 文本**, +否则 @ 完全不生效、连通知都没有。插件会自动补上:把 `@手机号` 追加到消息末尾; +如果你在自定义消息里**已经写了** `@手机号`,插件不会再追加一遍,@ 会留在你写的位置。 + +`TEXT` 类型**不需要**正文里有 `@手机号`——钉钉会自己把 @ 渲染出来(正文没写就追加到末尾, +写了就在原位渲染),所以插件对 `TEXT` 类型不做任何追加。 + +::: tip + +只有**在群内的成员**才可被 @。官方说明:非群内成员的手机号**会被脱敏**,此时 @ 完全不生效、也不会有通知。 +如果 @ 没有任何反应,先确认这个手机号是不是机器人所在群的成员。 + +::: + +勾选 `通知所有人` 时,插件**不再把手机号追加到正文**:所有人本来就会收到通知,而钉钉在 @所有人 的情况下 +不再解析单个手机号,追加上去只会在消息里留下一串没有变成姓名的号码。 + +## 各消息类型的差异 + +| 消息类型 | 是否支持 @ | 电脑端可点击 | 手机端可点击 | +|---|:---:|:---:|:---:| +| `TEXT` | ✅ | ✅ | **✅** | +| `MARKDOWN` | ✅ | ✅ | **❌** | +| `ACTION_CARD` | ✅ | ✅ | **❌** | +| `LINK` | ❌ | —— | —— | + +「是否支持 @」指的是**通知能否送达**。`MARKDOWN` 和 `ACTION_CARD` 的 @ **通知是正常送达的**, +被 @ 的人会收到提醒;差别只在手机端的**显示与交互**。 + +## 手机端 @ 不可点击是钉钉的行为 + +**插件的内置消息使用 `ACTION_CARD` 类型,因此它的 @ 在手机端不会高亮、也点不开。** +这不是插件的问题,钉钉对此有明确说明: + +> **@人是否高亮显示**:使用不同类型发送消息,被@后是否高亮显示效果不同。 +> +> | @效果 | 是否支持高亮 | +> |---|---| +> | Text类型 | 是 | +> | Markdown类型 | 否 | +> | ActionCard类型 | 否 | +> +> 机器人发送 Markdown 消息类型@人员,不支持高亮显示。 +> +> —— [钉钉开放平台《机器人发送消息类型与@人》](https://open.dingtalk.com/document/orgapp/robot-message-type-staff-information-in-an-enterprise) + +经实测,`MARKDOWN` / `ACTION_CARD` 里的 @ 在电脑端会被渲染出来并且可以点击, +**手机端则完全不渲染、点不开**;`TEXT` 类型两端都可以点击。 +换用 `atUserIds`(钉钉 userId)代替手机号**不会改变这一点**——决定因素是消息类型,不是标识符。 + +::: tip + +@ 的具体样式取决于你是不是被 @ 的那个人(官方对「查看 @ 自己的消息」和「@ 其他人的消息」分别举例), +所以判断 @ 有没有真正生效,看的是**能不能点开**,而不是颜色。 + +::: + +### 需要可点击的 @ 怎么办 + +在 pipeline 中使用 `TEXT` 类型发送: + +```groovy +dingtalk( + robot: 'robot-id', + type: 'TEXT', + text: ['构建完成,请查看'], + at: ['13800000000'] +) +``` + +代价是 `TEXT` 类型**不渲染任何 markdown**:标题、链接、颜色、按钮都不可用, +`#`、`[文字](链接)`、`` 都会原样显示。所以这是一个取舍—— +需要卡片样式就接受手机端的 @ 不可点击,需要可点击的 @ 就接受纯文本。 + +::: warning + +不要为了 @ 的交互效果把内置消息改成 `TEXT`——内置消息依赖 markdown 渲染标题、状态颜色和两个按钮, +换成 `TEXT` 后这些都会变成可见的原始标记。 + +::: diff --git a/docs/advance/user-property.md b/docs/advance/user-property.md index 4e98aeb1..18f37ae3 100644 --- a/docs/advance/user-property.md +++ b/docs/advance/user-property.md @@ -2,6 +2,13 @@ 为了实现 `执行人` 字段带 `@` 效果,需要为 Jenkins 用户补充相关信息 +::: warning + +内置消息使用 `ACTION_CARD` 类型,钉钉对这类消息的 @ **在手机端不高亮、也不可点击**(通知会正常送达)。 +这是钉钉的既有行为,详见 [@ 人](./at-mention.md)。 + +::: + 1. 打开 **Manage Users** ::: details 查看详细 diff --git a/docs/guide/pipeline.md b/docs/guide/pipeline.md index 4914bee6..57ef76bf 100644 --- a/docs/guide/pipeline.md +++ b/docs/guide/pipeline.md @@ -34,6 +34,13 @@ dingtalk( | at | List\ | 需要 @ 的手机号码 | | atAll | boolean | 是否 @ 全部 | +::: warning + +不同消息类型的 @ 表现不同:`MARKDOWN` 和 `ACTION_CARD` 的 @ 在**手机端不高亮、不可点击**, +只有 `TEXT` 类型两端都可点击。详见 [@ 人](../advance/at-mention.md)。 + +::: + ::: tip `robot` 参数可以在机器人配置中找到 diff --git a/src/main/java/io/jenkins/plugins/DingTalkRunListener.java b/src/main/java/io/jenkins/plugins/DingTalkRunListener.java index feb65455..939de52b 100644 --- a/src/main/java/io/jenkins/plugins/DingTalkRunListener.java +++ b/src/main/java/io/jenkins/plugins/DingTalkRunListener.java @@ -305,6 +305,8 @@ private void send(Run run, TaskListener listener, NoticeOccasionEnum notic MessageModel msgModel = item.isRaw() ? MessageModel.builder() .type(MsgTypeEnum.MARKDOWN) + .atAll(atAll) + .atMobiles(atMobiles) .title(title) .text(envVars.expand(rawMessage).replace("\\\\n", "\n")) .build() diff --git a/src/main/java/io/jenkins/plugins/model/MessageModel.java b/src/main/java/io/jenkins/plugins/model/MessageModel.java index 36fca594..3eb0cb46 100644 --- a/src/main/java/io/jenkins/plugins/model/MessageModel.java +++ b/src/main/java/io/jenkins/plugins/model/MessageModel.java @@ -93,7 +93,7 @@ public At getAt() { .filter(StringUtils::isNotEmpty) .collect(Collectors.toList())); } - at.setIsAtAll(atAll); + at.setAtAll(atAll); return at; } diff --git a/src/main/java/io/jenkins/plugins/sdk/DingTalkRobotRequest.java b/src/main/java/io/jenkins/plugins/sdk/DingTalkRobotRequest.java index d49ed8cb..65f2389f 100644 --- a/src/main/java/io/jenkins/plugins/sdk/DingTalkRobotRequest.java +++ b/src/main/java/io/jenkins/plugins/sdk/DingTalkRobotRequest.java @@ -46,7 +46,7 @@ public static class At { /** 被 @ 人的手机号 */ private List atMobiles; /** 是否 @ 所有人 */ - private Boolean isAtAll; + private boolean isAtAll; } /** diff --git a/src/main/java/io/jenkins/plugins/sdk/DingTalkSender.java b/src/main/java/io/jenkins/plugins/sdk/DingTalkSender.java index 9c5f2117..f425e97f 100644 --- a/src/main/java/io/jenkins/plugins/sdk/DingTalkSender.java +++ b/src/main/java/io/jenkins/plugins/sdk/DingTalkSender.java @@ -25,6 +25,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -58,14 +59,16 @@ public DingTalkSender(DingTalkRobotConfig robotConfig, Proxy proxy) { /** * 发送 text 类型的消息 * + *

Unlike markdown and actionCard, DingTalk renders the mention for this type from the + * {@code at} object on its own — appending one to the content as well gets it rendered twice. + * * @param msg 消息 * @return 异常信息 */ public String sendText(MessageModel msg) { - At at = msg.getAt(); Text text = new Text(); - text.setAt(at); - text.setContent(addKeyWord(addAtInfo(msg.getText(), at, false))); + text.setAt(msg.getAt()); + text.setContent(addKeyWord(msg.getText())); return call(text); } @@ -73,15 +76,17 @@ public String sendText(MessageModel msg) { /** * 发送 link 类型的消息 * + *

DingTalk documents the link type as not supporting mentions at all: the {@code at} object is + * ignored, and an appended {@code @} is never resolved — it would just leave a bare phone + * number in the message and notify nobody. + * * @param msg 消息 * @return 异常信息 */ public String sendLink(MessageModel msg) { - At at = msg.getAt(); Link link = new Link(); - link.setAt(at); link.setTitle(addKeyWord(msg.getTitle())); - link.setText(addAtInfo(msg.getText(), at, false)); + link.setText(msg.getText()); link.setMessageUrl(msg.getMessageUrl()); link.setPicUrl(msg.getPicUrl()); @@ -93,7 +98,7 @@ public String sendMarkdown(MessageModel msg) { Markdown markdown = new Markdown(); markdown.setAt(at); markdown.setTitle(addKeyWord(msg.getTitle())); - markdown.setText(addAtInfo(msg.getText(), at, true)); + markdown.setText(addAtInfo(msg.getText(), at)); return call(markdown); } @@ -103,7 +108,7 @@ public String sendActionCard(MessageModel msg) { ActionCard actioncard = new ActionCard(); actioncard.setAt(at); actioncard.setTitle(addKeyWord(msg.getTitle())); - actioncard.setText(addAtInfo(msg.getText(), at, true)); + actioncard.setText(addAtInfo(msg.getText(), at)); String singleTitle = msg.getSingleTitle(); if (StringUtils.isEmpty(singleTitle)) { actioncard.setBtns(msg.getRobotBtns()); @@ -185,20 +190,35 @@ private String addKeyWord(String str) { /** * 添加 at 信息 * + *

Only markdown and actionCard need this. A mobile never resolves for them unless the body + * carries its {@code @} token, and actionCard needs one for {@code isAtAll} as well; + * markdown is the single case that notifies without a token, and even there the token is what + * makes the mention visible. Text messages are the other way round — DingTalk renders their + * mention itself — and the link type supports none. + * + *

Tokens the caller already placed in the body are skipped, so a hand-written template keeps + * its mention where the author put it instead of getting a second copy appended. + * * @param content 原始内容 * @param at at 配置 - * @param markdown 是否是 markdown 格式的内容 * @return 包含 at 信息的内容 */ - private String addAtInfo(String content, At at, boolean markdown) { - List atMobiles = at.getAtMobiles(); - if (atMobiles == null || atMobiles.isEmpty()) { - return content; + private String addAtInfo(String content, At at) { + String body = StringUtils.defaultString(content); + List tokens = new ArrayList<>(); + if (at.isAtAll()) { + // Mentioning everyone stops DingTalk from resolving individual mobiles, so adding them here + // as well would leave bare phone numbers in the message where a name should have appeared — + // and everybody has already been notified anyway. + tokens.add(Constants.AT_ALL); + } else if (at.getAtMobiles() != null) { + at.getAtMobiles().forEach(mobile -> tokens.add("@" + mobile)); } - String atContent = "@" + StringUtils.join(atMobiles, " @"); - if (markdown) { - return content + "\n\n" + Utils.dye(atContent, Constants.COLOR_BLUE) + "\n"; + tokens.removeIf(body::contains); + if (tokens.isEmpty()) { + return body; } - return content + atContent; + String atContent = String.join(" ", tokens); + return body + "\n\n" + Utils.dye(atContent, Constants.COLOR_BLUE) + "\n"; } } diff --git a/src/main/java/io/jenkins/plugins/tools/Constants.java b/src/main/java/io/jenkins/plugins/tools/Constants.java index 805e06b0..3f1b8f56 100644 --- a/src/main/java/io/jenkins/plugins/tools/Constants.java +++ b/src/main/java/io/jenkins/plugins/tools/Constants.java @@ -16,6 +16,11 @@ public class Constants { // Content Types public static final String CONTENT_TYPE_APPLICATION_JSON = "application/json"; + // Mentions + // The literal DingTalk resolves into a mention of the whole group. Its English spellings are + // delivered but not resolved, so this string cannot be localised. + public static final String AT_ALL = "@所有人"; + // API Endpoints public static final String DINGTALK_WEBHOOK_URL_PREFIX = "https://oapi.dingtalk.com/robot/send?access_token="; diff --git a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/config.jelly b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/config.jelly index 39fe3e75..b4dd3d35 100644 --- a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/config.jelly +++ b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/config.jelly @@ -34,16 +34,16 @@ + + + +

- - - - diff --git a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at.html b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at.html index ec862cd5..68171161 100644 --- a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at.html +++ b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at.html @@ -2,4 +2,11 @@ atAll: Is it @ everyone?
Textarea: The mobile phone number configured in DingTalk, used to @ mention the corresponding person. Separate multiple values with commas or line breaks, supports environment variables. +
+
+ With Notify everyone ticked the mobile numbers are no longer added to the message — everyone is notified anyway. +
+ Only members of the group can be mentioned; DingTalk masks the number of anyone who is not. +
+ DingTalk does not highlight a mention in a Markdown or ActionCard message, and it is not tappable on the mobile client — the notification itself is still delivered. This is DingTalk's own behaviour.
diff --git a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at_zh_CN.html b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at_zh_CN.html index 17260236..88bc0ade 100644 --- a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at_zh_CN.html +++ b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-at_zh_CN.html @@ -2,4 +2,12 @@ atAll: 是否 @ 全部。
输入框: 钉钉里面配置的手机号码,用于 @ 对应的人。多个值使用逗号或者换行分割,支持环境变量。 +
+
+ 勾选 通知所有人 时,手机号列表不再追加到消息里——所有人都会收到通知。 +
+ 只有群内成员的手机号才能被 @,非群内成员的号码会被钉钉脱敏。 +
+ 钉钉对 Markdown / ActionCard 类型消息的 @ 在手机端不高亮、也不可点击(通知会正常送达), + 这是钉钉的既有行为。 diff --git a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw.html b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw.html index d9b0ce19..6d4b7a17 100644 --- a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw.html +++ b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw.html @@ -1,3 +1,5 @@
Disabling the plugin's built-in encapsulation message, only sends the Custom Message below, which is equivalent to fully customizing the message using markdown. +
+ Notify people still applies: the mentions configured there are appended to the message, unless it already contains them.
diff --git a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw_zh_CN.html b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw_zh_CN.html index c587585a..810d04ea 100644 --- a/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw_zh_CN.html +++ b/src/main/resources/io/jenkins/plugins/DingTalkNotifierConfig/help-raw_zh_CN.html @@ -1,3 +1,5 @@
禁用插件内部封装的消息仅发送下方的 自定义消息,相当于使用 markdown 完全自定义消息 +
+ 通知人 依然生效:其中配置的 @ 信息会追加到消息里,除非消息里已经写了。
diff --git a/src/test/java/io/jenkins/plugins/CapturingWebhook.java b/src/test/java/io/jenkins/plugins/CapturingWebhook.java new file mode 100644 index 00000000..a69bed97 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/CapturingWebhook.java @@ -0,0 +1,66 @@ +package io.jenkins.plugins; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * A local stand-in for the DingTalk endpoint that records what the plugin actually posted. + * + *

The robot webhook is configurable in full, so pointing it here captures the expanded payload + * without touching plugin code or needing a real token. + */ +public final class CapturingWebhook implements AutoCloseable { + + private static final byte[] OK = "{\"errcode\":0,\"errmsg\":\"ok\"}".getBytes(StandardCharsets.UTF_8); + + private final HttpServer server; + private final List bodies = new ArrayList<>(); + + public CapturingWebhook() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/robot/send", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + synchronized (bodies) { + bodies.add(body); + } + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, OK.length); + exchange.getResponseBody().write(OK); + exchange.close(); + }); + server.start(); + } + + public String url() { + InetSocketAddress address = server.getAddress(); + return "http://" + address.getHostString() + ":" + address.getPort() + + "/robot/send?access_token=test"; + } + + public List bodies() { + synchronized (bodies) { + return new ArrayList<>(bodies); + } + } + + /** The single captured payload, parsed. Fails loudly if the count is not exactly one. */ + public JsonObject onlyPayload() { + List captured = bodies(); + if (captured.size() != 1) { + throw new AssertionError("expected exactly one request, captured " + captured); + } + return JsonParser.parseString(captured.get(0)).getAsJsonObject(); + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/src/test/java/io/jenkins/plugins/DingTalkRunListenerAtTest.java b/src/test/java/io/jenkins/plugins/DingTalkRunListenerAtTest.java new file mode 100644 index 00000000..97b95299 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/DingTalkRunListenerAtTest.java @@ -0,0 +1,86 @@ +package io.jenkins.plugins; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import hudson.model.FreeStyleProject; +import io.jenkins.plugins.enums.NoticeOccasionEnum; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +/** Covers the mention settings reaching the payload for both notification modes. */ +@WithJenkins +class DingTalkRunListenerAtTest { + + private static final String ROBOT_ID = "robot-under-test"; + private static final String MOBILE = "13800000000"; + + private static FreeStyleProject jobNotifying( + JenkinsRule r, CapturingWebhook webhook, String name, boolean raw) throws Exception { + ArrayList robots = new ArrayList<>(); + robots.add(new DingTalkRobotConfig(ROBOT_ID, "robot", webhook.url(), new ArrayList<>())); + DingTalkGlobalConfig.getInstance().setRobotConfigs(robots); + + DingTalkNotifierConfig notifier = new DingTalkNotifierConfig( + raw, + false, + true, + ROBOT_ID, + "robot", + false, + MOBILE, + raw ? null : "built-in body", + raw ? "custom body" : null, + // Not Set.of(): JEP-200 class filtering allows ImmutableCollections$List12/ListN but has + // no Set or Map counterpart, so saving the job config would fail. + new HashSet<>(List.of(NoticeOccasionEnum.SUCCESS.name()))); + ArrayList notifiers = new ArrayList<>(); + notifiers.add(notifier); + + FreeStyleProject job = r.createFreeStyleProject(name); + job.addProperty(new DingTalkJobProperty(notifiers)); + return job; + } + + /** + * A mention needs both halves to arrive: the mobile in the {@code at} object decides who gets + * notified, the token in the body is what DingTalk resolves into a name. + */ + private static void assertMentionsConfiguredPeople(JsonObject payload, String type) { + JsonObject at = payload.getAsJsonObject("at"); + assertEquals( + MOBILE, + at.getAsJsonArray("atMobiles").get(0).getAsString(), + () -> "atMobiles not sent: " + payload); + + String text = payload.getAsJsonObject(type).get("text").getAsString(); + assertTrue(text.contains("@" + MOBILE), () -> "mobile missing from the body: " + text); + } + + /** + * The raw branch used to build its MessageModel without atAll/atMobiles, so "disable built-in + * message" silently dropped every mention. + */ + @Test + void rawNotificationCarriesTheConfiguredMentions(JenkinsRule r) throws Exception { + try (CapturingWebhook webhook = new CapturingWebhook()) { + r.buildAndAssertSuccess(jobNotifying(r, webhook, "raw-notification", true)); + + assertMentionsConfiguredPeople(webhook.onlyPayload(), "markdown"); + } + } + + @Test + void builtInNotificationCarriesTheConfiguredMentions(JenkinsRule r) throws Exception { + try (CapturingWebhook webhook = new CapturingWebhook()) { + r.buildAndAssertSuccess(jobNotifying(r, webhook, "built-in-notification", false)); + + assertMentionsConfiguredPeople(webhook.onlyPayload(), "actionCard"); + } + } +} diff --git a/src/test/java/io/jenkins/plugins/sdk/DingTalkSenderAtTest.java b/src/test/java/io/jenkins/plugins/sdk/DingTalkSenderAtTest.java new file mode 100644 index 00000000..485ffecd --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sdk/DingTalkSenderAtTest.java @@ -0,0 +1,162 @@ +package io.jenkins.plugins.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import io.jenkins.plugins.CapturingWebhook; +import io.jenkins.plugins.DingTalkRobotConfig; +import io.jenkins.plugins.enums.MsgTypeEnum; +import io.jenkins.plugins.model.MessageModel; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; + +/** + * Covers which mention tokens end up in the message body. + * + *

DingTalk only resolves a mention when the {@code at} object and a matching token in the + * body are both present, so the body is what these tests assert on. + */ +class DingTalkSenderAtTest { + + private static final String MOBILE = "13800000000"; + private static final String AT_ALL = "@所有人"; + + private static DingTalkSender senderFor(CapturingWebhook webhook) { + return new DingTalkSender( + new DingTalkRobotConfig("robot", "robot", webhook.url(), new ArrayList<>()), null); + } + + private static Set mobiles(String... values) { + return new LinkedHashSet<>(List.of(values)); + } + + private static String textOf(JsonObject payload, String type) { + return payload.getAsJsonObject(type).get("text").getAsString(); + } + + @Test + void appendsTheAtAllTokenSoDingTalkResolvesIt() throws IOException { + try (CapturingWebhook webhook = new CapturingWebhook()) { + senderFor(webhook).sendActionCard(MessageModel.builder() + .type(MsgTypeEnum.ACTION_CARD) + .atAll(true) + .title("t") + .text("# build ok") + .build()); + + assertTrue(textOf(webhook.onlyPayload(), "actionCard").contains(AT_ALL)); + } + } + + /** + * DingTalk stops resolving individual mobiles once everyone is mentioned, so a mobile appended + * alongside {@code @所有人} would stay a bare phone number in the message body. + */ + @Test + void leavesMobilesOutWhenEveryoneIsMentioned() throws IOException { + try (CapturingWebhook webhook = new CapturingWebhook()) { + senderFor(webhook).sendMarkdown(MessageModel.builder() + .type(MsgTypeEnum.MARKDOWN) + .atAll(true) + .atMobiles(mobiles(MOBILE)) + .title("t") + .text("# build ok") + .build()); + + String text = textOf(webhook.onlyPayload(), "markdown"); + assertTrue(text.contains(AT_ALL), () -> "@所有人 missing: " + text); + assertFalse(text.contains(MOBILE), () -> "bare phone number left in the body: " + text); + } + } + + @Test + void doesNotRepeatAnAtAllTokenTheTextAlreadyCarries() throws IOException { + try (CapturingWebhook webhook = new CapturingWebhook()) { + senderFor(webhook).sendMarkdown(MessageModel.builder() + .type(MsgTypeEnum.MARKDOWN) + .atAll(true) + .title("t") + .text("heads up " + AT_ALL + " please review") + .build()); + + String text = textOf(webhook.onlyPayload(), "markdown"); + assertEquals(1, StringUtils.countMatches(text, AT_ALL), () -> "duplicated mention: " + text); + } + } + + @Test + void doesNotRepeatAMobileTheTextAlreadyMentions() throws IOException { + try (CapturingWebhook webhook = new CapturingWebhook()) { + senderFor(webhook).sendMarkdown(MessageModel.builder() + .type(MsgTypeEnum.MARKDOWN) + .atMobiles(mobiles(MOBILE)) + .title("t") + .text("start @" + MOBILE + " end") + .build()); + + String text = textOf(webhook.onlyPayload(), "markdown"); + assertEquals(1, StringUtils.countMatches(text, "@" + MOBILE), () -> "duplicated: " + text); + } + } + + @Test + void appendsMobilesTheTextDoesNotMention() throws IOException { + try (CapturingWebhook webhook = new CapturingWebhook()) { + senderFor(webhook).sendMarkdown(MessageModel.builder() + .type(MsgTypeEnum.MARKDOWN) + .atMobiles(mobiles(MOBILE)) + .title("t") + .text("nothing mentioned here") + .build()); + + assertTrue(textOf(webhook.onlyPayload(), "markdown").contains("@" + MOBILE)); + } + } + + /** + * DingTalk renders a text message's mention from the at object by itself, and appends its own copy + * even when the content already carries the token — so anything added here shows up twice. + */ + @Test + void addsNoMentionToTextMessagesBecauseDingTalkRendersThem() throws IOException { + try (CapturingWebhook webhook = new CapturingWebhook()) { + senderFor(webhook).sendText(MessageModel.builder() + .type(MsgTypeEnum.TEXT) + .atAll(true) + .atMobiles(mobiles(MOBILE)) + .text("build finished") + .build()); + + JsonObject payload = webhook.onlyPayload(); + String content = payload.getAsJsonObject("text").get("content").getAsString(); + assertEquals("build finished", content, () -> "content was rewritten: " + content); + // The at object still has to arrive — it is what DingTalk renders the mention from. + assertTrue(payload.getAsJsonObject("at").get("isAtAll").getAsBoolean(), () -> "" + payload); + } + } + + @Test + void addsNoMentionToLinkMessagesBecauseDingTalkIgnoresThem() throws IOException { + try (CapturingWebhook webhook = new CapturingWebhook()) { + senderFor(webhook).sendLink(MessageModel.builder() + .type(MsgTypeEnum.LINK) + .atAll(true) + .atMobiles(mobiles(MOBILE)) + .title("t") + .text("see the build") + .messageUrl("https://example.com") + .build()); + + String body = webhook.bodies().get(0); + assertFalse(body.contains(MOBILE), () -> "link carries a mention it cannot deliver: " + body); + assertFalse(body.contains(AT_ALL), () -> "link carries a mention it cannot deliver: " + body); + } + } +}