mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(feishu): support Interactive Card JSON for structured message rendering (#161)
Auto-route Agent replies to Feishu Interactive Card (schema 2.0) when the content carries structure — JSON object / array, Markdown with code blocks or headings, or long-form prose — and keep the original text path for short plain replies. - FeishuCardFormatter: package-private detect() + render() helper - JSON object → two-column summary card - JSON array (≤4 fields) → table component; (>4 fields) → div per item - Markdown → lark_md card with 'AI 助手' header - Long text (>300 chars with paragraph breaks) → plain_text card - JSON embedded in Markdown code blocks is recognised across all fences - FeishuChannelAdapter - sendMessage() honours channel config 'card_format' (auto | always | never) - sendCard() POSTs interactive messages; ou_-prefixed targets use open_id - updateCard() PATCHes an existing message (streaming-update hook) - Tests: 32 unit cases covering every detect path and render branch Closes #141
This commit is contained in:
parent
db16ff02a5
commit
ee0c229f52
@ -0,0 +1,203 @@
|
||||
package vip.mate.channel.feishu;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class FeishuCardFormatter {
|
||||
|
||||
enum ContentFormat { JSON, MARKDOWN, LONG_TEXT, PLAIN_TEXT }
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final int JSON_MAX_LEN = 32_000;
|
||||
private static final Pattern HEADER = Pattern.compile("(?m)^#{1,6}\\s");
|
||||
private static final Pattern TABLE_SEP = Pattern.compile("(?m)^\\|[\\s|:-]+\\|\\s*$");
|
||||
private static final Pattern JSON_CODE_BLOCK =
|
||||
Pattern.compile("(?s)```(?:json)?\\s*([\\[{][\\s\\S]*?[\\]\\}])\\s*```");
|
||||
|
||||
private FeishuCardFormatter() {}
|
||||
|
||||
static ContentFormat detect(String content) {
|
||||
if (content == null || content.isBlank()) return ContentFormat.PLAIN_TEXT;
|
||||
String s = content.trim();
|
||||
|
||||
if ((s.startsWith("{") || s.startsWith("[")) && s.length() <= JSON_MAX_LEN) {
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(s);
|
||||
if (node.isObject() && !node.isEmpty()) return ContentFormat.JSON;
|
||||
if (node.isArray() && node.size() > 0 && node.get(0).isObject()) return ContentFormat.JSON;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
if (s.contains("```")) {
|
||||
Matcher cm = JSON_CODE_BLOCK.matcher(s);
|
||||
while (cm.find()) {
|
||||
String extracted = cm.group(1).strip();
|
||||
if (extracted.length() <= JSON_MAX_LEN) {
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(extracted);
|
||||
if (node.isObject() && !node.isEmpty()) return ContentFormat.JSON;
|
||||
if (node.isArray() && node.size() > 0 && node.get(0).isObject()) return ContentFormat.JSON;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
return ContentFormat.MARKDOWN;
|
||||
}
|
||||
if (HEADER.matcher(s).find()) return ContentFormat.MARKDOWN;
|
||||
if (TABLE_SEP.matcher(s).find()) return ContentFormat.MARKDOWN;
|
||||
if (bulletCount(s) >= 2) return ContentFormat.MARKDOWN;
|
||||
|
||||
if (s.length() > 300 && s.contains("\n\n")) return ContentFormat.LONG_TEXT;
|
||||
|
||||
return ContentFormat.PLAIN_TEXT;
|
||||
}
|
||||
|
||||
private static long bulletCount(String s) {
|
||||
return s.lines()
|
||||
.filter(line -> {
|
||||
String t = line.stripLeading();
|
||||
return t.startsWith("- ") || t.startsWith("* ")
|
||||
|| t.matches("^\\d+\\.\\s.*");
|
||||
})
|
||||
.count();
|
||||
}
|
||||
|
||||
private static String extractJsonCodeBlock(String s) {
|
||||
Matcher m = JSON_CODE_BLOCK.matcher(s);
|
||||
return m.find() ? m.group(1).strip() : null;
|
||||
}
|
||||
|
||||
// ==================== 渲染层 ====================
|
||||
|
||||
static Map<String, Object> render(String content, ContentFormat format) {
|
||||
return switch (format) {
|
||||
case JSON -> renderJson(content);
|
||||
case MARKDOWN -> renderMarkdown(content);
|
||||
case LONG_TEXT, PLAIN_TEXT -> renderLongText(content);
|
||||
};
|
||||
}
|
||||
|
||||
private static Map<String, Object> renderMarkdown(String content) {
|
||||
return cardOf(
|
||||
Map.of("title", Map.of("tag", "plain_text", "content", "AI 助手")),
|
||||
List.of(Map.of(
|
||||
"tag", "div",
|
||||
"text", Map.of("tag", "lark_md", "content", content)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private static Map<String, Object> renderLongText(String content) {
|
||||
return cardOf(
|
||||
null,
|
||||
List.of(Map.of(
|
||||
"tag", "div",
|
||||
"text", Map.of("tag", "plain_text", "content", content)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private static Map<String, Object> renderJson(String content) {
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(content);
|
||||
if (node.isObject()) return renderJsonObject(node);
|
||||
if (node.isArray()) return renderJsonArray(node);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
Matcher rm = JSON_CODE_BLOCK.matcher(content);
|
||||
while (rm.find()) {
|
||||
String extracted = rm.group(1).strip();
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(extracted);
|
||||
if (node.isObject()) return renderJsonObject(node);
|
||||
if (node.isArray()) return renderJsonArray(node);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
return renderLongText(content);
|
||||
}
|
||||
|
||||
private static Map<String, Object> renderJsonObject(JsonNode node) {
|
||||
List<Object> elements = new ArrayList<>();
|
||||
node.fields().forEachRemaining(entry -> {
|
||||
String key = entry.getKey();
|
||||
String value = entry.getValue().isTextual()
|
||||
? entry.getValue().asText()
|
||||
: entry.getValue().toString();
|
||||
elements.add(Map.of(
|
||||
"tag", "column_set",
|
||||
"flex_mode", "none",
|
||||
"columns", List.of(
|
||||
Map.of("tag", "column", "width", "weighted", "weight", 1,
|
||||
"elements", List.of(Map.of("tag", "div",
|
||||
"text", Map.of("tag", "plain_text", "content", key)))),
|
||||
Map.of("tag", "column", "width", "weighted", "weight", 2,
|
||||
"elements", List.of(Map.of("tag", "div",
|
||||
"text", Map.of("tag", "plain_text", "content", value))))
|
||||
)
|
||||
));
|
||||
});
|
||||
return cardOf(null, elements);
|
||||
}
|
||||
|
||||
private static Map<String, Object> renderJsonArray(JsonNode array) {
|
||||
JsonNode first = array.get(0);
|
||||
List<String> fields = new ArrayList<>();
|
||||
first.fieldNames().forEachRemaining(fields::add);
|
||||
return fields.size() <= 4
|
||||
? renderJsonTable(array, fields)
|
||||
: renderJsonList(array);
|
||||
}
|
||||
|
||||
private static Map<String, Object> renderJsonTable(JsonNode array, List<String> fields) {
|
||||
List<Map<String, Object>> columns = fields.stream()
|
||||
.<Map<String, Object>>map(name -> Map.of("name", name, "display_name", name))
|
||||
.toList();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (JsonNode item : array) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for (String field : fields) {
|
||||
JsonNode val = item.get(field);
|
||||
row.put(field, val == null ? "" : (val.isTextual() ? val.asText() : val.toString()));
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
Map<String, Object> table = new LinkedHashMap<>();
|
||||
table.put("tag", "table");
|
||||
table.put("columns", columns);
|
||||
table.put("rows", rows);
|
||||
table.put("page_size", 10);
|
||||
table.put("row_height", "low");
|
||||
return cardOf(null, List.of(table));
|
||||
}
|
||||
|
||||
private static Map<String, Object> renderJsonList(JsonNode array) {
|
||||
List<Object> elements = new ArrayList<>();
|
||||
for (JsonNode item : array) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
item.fields().forEachRemaining(e -> {
|
||||
String val = e.getValue().isTextual() ? e.getValue().asText() : e.getValue().toString();
|
||||
sb.append("**").append(e.getKey()).append("**: ").append(val).append("\n");
|
||||
});
|
||||
elements.add(Map.of(
|
||||
"tag", "div",
|
||||
"text", Map.of("tag", "lark_md", "content", sb.toString().trim())
|
||||
));
|
||||
}
|
||||
return cardOf(null, elements);
|
||||
}
|
||||
|
||||
private static Map<String, Object> cardOf(Map<String, Object> header, List<?> elements) {
|
||||
Map<String, Object> card = new LinkedHashMap<>();
|
||||
card.put("schema", "2.0");
|
||||
card.put("config", Map.of("wide_screen_mode", true));
|
||||
if (header != null) card.put("header", header);
|
||||
card.put("body", Map.of("elements", elements));
|
||||
return card;
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,8 @@ import java.util.concurrent.TimeUnit;
|
||||
* - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt(默认 true)
|
||||
* - silent_disconnect_threshold_seconds: WebSocket 静默断连阈值(默认 1800,0 禁用)
|
||||
* - stale_event_threshold_seconds: 过滤旧事件阈值(默认 30,0 禁用)
|
||||
* - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never"
|
||||
* auto: 根据内容自动检测;always: 全部包卡片;never: 全部纯文本(降级/调试用)
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@ -1244,13 +1246,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
||||
}
|
||||
|
||||
ensureTokenValid();
|
||||
List<String> chunks = splitTextForFeishu(content, MAX_TEXT_MESSAGE_CHARS);
|
||||
if (chunks.size() > 1) {
|
||||
log.info("[feishu] Splitting message into {} chunks ({} chars total) before send",
|
||||
chunks.size(), content.length());
|
||||
|
||||
String cardFormat = getConfigString("card_format", "auto");
|
||||
|
||||
if ("never".equals(cardFormat)) {
|
||||
splitTextForFeishu(content, MAX_TEXT_MESSAGE_CHARS)
|
||||
.forEach(c -> sendOneTextChunk(targetId, c));
|
||||
return;
|
||||
}
|
||||
for (String chunk : chunks) {
|
||||
sendOneTextChunk(targetId, chunk);
|
||||
|
||||
FeishuCardFormatter.ContentFormat fmt = FeishuCardFormatter.detect(content);
|
||||
|
||||
if ("always".equals(cardFormat) || fmt != FeishuCardFormatter.ContentFormat.PLAIN_TEXT) {
|
||||
sendCard(targetId, FeishuCardFormatter.render(content, fmt));
|
||||
} else {
|
||||
splitTextForFeishu(content, MAX_TEXT_MESSAGE_CHARS)
|
||||
.forEach(c -> sendOneTextChunk(targetId, c));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1282,6 +1293,66 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
public void sendCard(String targetId, Map<String, Object> cardJson) {
|
||||
if (httpClient == null) {
|
||||
log.warn("[feishu] Channel not started, cannot send card");
|
||||
return;
|
||||
}
|
||||
ensureTokenValid();
|
||||
String apiBase = getApiBaseUrl();
|
||||
String receiveIdType = targetId.startsWith("ou_") ? "open_id" : "chat_id";
|
||||
try {
|
||||
String jsonBody = objectMapper.writeValueAsString(Map.of(
|
||||
"receive_id", targetId,
|
||||
"msg_type", "interactive",
|
||||
"content", objectMapper.writeValueAsString(cardJson)
|
||||
));
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(apiBase + "/open-apis/im/v1/messages?receive_id_type=" + receiveIdType))
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.header("Authorization", "Bearer " + tenantAccessToken)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
log.warn("[feishu] Send card failed: status={}, body={}", response.statusCode(), response.body());
|
||||
} else {
|
||||
log.debug("[feishu] Card sent to {} (type={})", targetId, receiveIdType);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[feishu] Failed to send card: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateCard(String messageId, Map<String, Object> cardJson) {
|
||||
if (httpClient == null) {
|
||||
log.warn("[feishu] Channel not started, cannot update card");
|
||||
return;
|
||||
}
|
||||
ensureTokenValid();
|
||||
String apiBase = getApiBaseUrl();
|
||||
try {
|
||||
String jsonBody = objectMapper.writeValueAsString(Map.of(
|
||||
"msg_type", "interactive",
|
||||
"content", objectMapper.writeValueAsString(cardJson)
|
||||
));
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(apiBase + "/open-apis/im/v1/messages/" + messageId))
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.header("Authorization", "Bearer " + tenantAccessToken)
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
log.warn("[feishu] Update card failed: status={}, body={}", response.statusCode(), response.body());
|
||||
} else {
|
||||
log.debug("[feishu] Card updated: messageId={}", messageId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[feishu] Failed to update card: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a possibly-oversized message into chunks no larger than
|
||||
* {@code maxChars}, preferring paragraph (\n\n) then line (\n) then
|
||||
|
||||
@ -0,0 +1,273 @@
|
||||
package vip.mate.channel.feishu;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static vip.mate.channel.feishu.FeishuCardFormatter.ContentFormat.*;
|
||||
|
||||
class FeishuCardFormatterTest {
|
||||
|
||||
// ==================== detect() ====================
|
||||
|
||||
@Test
|
||||
void detect_nullAndBlank_returnsPlainText() {
|
||||
assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect(null));
|
||||
assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect(""));
|
||||
assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_nonEmptyJsonObject_returnsJson() {
|
||||
assertEquals(JSON, FeishuCardFormatter.detect("{\"key\": \"value\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_jsonArrayOfObjects_returnsJson() {
|
||||
assertEquals(JSON, FeishuCardFormatter.detect("[{\"a\": 1, \"b\": 2}]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_emptyJsonObject_doesNotReturnJson() {
|
||||
assertNotEquals(JSON, FeishuCardFormatter.detect("{}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_primitiveArray_doesNotReturnJson() {
|
||||
assertNotEquals(JSON, FeishuCardFormatter.detect("[1, 2, 3]"));
|
||||
assertNotEquals(JSON, FeishuCardFormatter.detect("[\"a\", \"b\"]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_invalidJsonStartingWithBrace_doesNotReturnJson() {
|
||||
assertNotEquals(JSON, FeishuCardFormatter.detect("{invalid json}"));
|
||||
assertNotEquals(JSON, FeishuCardFormatter.detect("[引用消息: 你好]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_jsonOverSizeLimit_doesNotReturnJson() {
|
||||
String big = "{\"k\":\"" + "x".repeat(32_000) + "\"}";
|
||||
assertNotEquals(JSON, FeishuCardFormatter.detect(big));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_codeBlock_returnsMarkdown() {
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect("看这段代码:\n```java\nint x = 1;\n```"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_h2Header_returnsMarkdown() {
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect("## 标题\n正文内容"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_h1Header_returnsMarkdown() {
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect("# 一级标题"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_tableSeparatorRow_returnsMarkdown() {
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect("| A | B |\n|---|---|\n| 1 | 2 |"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_hrTripleDash_doesNotReturnMarkdown() {
|
||||
assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect("---"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_twoBulletItems_returnsMarkdown() {
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect("- 第一条\n- 第二条"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_oneBulletItem_doesNotReturnMarkdown() {
|
||||
assertNotEquals(MARKDOWN, FeishuCardFormatter.detect("- 只有一条"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_inlineDashNotBullet_doesNotReturnMarkdown() {
|
||||
String text = "价格 - 折扣 = 净价\n成本 - 税 = 实际";
|
||||
assertNotEquals(MARKDOWN, FeishuCardFormatter.detect(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_longTextWithDoubleNewline_returnsLongText() {
|
||||
String text = "x".repeat(150) + "\n\n" + "y".repeat(155);
|
||||
assertEquals(LONG_TEXT, FeishuCardFormatter.detect(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_longTextWithoutDoubleNewline_returnsPlainText() {
|
||||
assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect("x".repeat(400)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_shortPlainText_returnsPlainText() {
|
||||
assertEquals(PLAIN_TEXT, FeishuCardFormatter.detect("好的,明白了。"));
|
||||
}
|
||||
|
||||
// ==================== render() ====================
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void render_markdown_hasSchema20AndLarkMdElement() {
|
||||
String md = "## 标题\n- 第一条\n- 第二条";
|
||||
var card = FeishuCardFormatter.render(md, MARKDOWN);
|
||||
|
||||
assertEquals("2.0", card.get("schema"));
|
||||
assertNotNull(card.get("header"));
|
||||
var body = (java.util.Map<String, Object>) card.get("body");
|
||||
var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements");
|
||||
assertEquals("div", elems.get(0).get("tag"));
|
||||
var text = (java.util.Map<String, Object>) elems.get(0).get("text");
|
||||
assertEquals("lark_md", text.get("tag"));
|
||||
assertEquals(md, text.get("content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void render_longText_hasNoHeaderAndPlainTextElement() {
|
||||
String content = "x".repeat(150) + "\n\n" + "y".repeat(155);
|
||||
var card = FeishuCardFormatter.render(content, LONG_TEXT);
|
||||
|
||||
assertEquals("2.0", card.get("schema"));
|
||||
assertNull(card.get("header"));
|
||||
var body = (java.util.Map<String, Object>) card.get("body");
|
||||
var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements");
|
||||
var text = (java.util.Map<String, Object>) elems.get(0).get("text");
|
||||
assertEquals("plain_text", text.get("tag"));
|
||||
assertEquals(content, text.get("content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void render_jsonObject_hasColumnSetPerField() {
|
||||
var card = FeishuCardFormatter.render("{\"name\":\"Alice\",\"score\":95}", JSON);
|
||||
|
||||
assertEquals("2.0", card.get("schema"));
|
||||
assertNull(card.get("header")); // 摘要卡片无 header
|
||||
var body = (java.util.Map<String, Object>) card.get("body");
|
||||
var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements");
|
||||
assertEquals(2, elems.size()); // 2 个字段 → 2 个 column_set
|
||||
assertEquals("column_set", elems.get(0).get("tag"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void render_jsonArrayFewColumns_usesTableComponent() {
|
||||
var card = FeishuCardFormatter.render("[{\"a\":1,\"b\":2},{\"a\":3,\"b\":4}]", JSON);
|
||||
|
||||
var body = (java.util.Map<String, Object>) card.get("body");
|
||||
var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements");
|
||||
var table = elems.get(0);
|
||||
assertEquals("table", table.get("tag"));
|
||||
|
||||
var columns = (java.util.List<java.util.Map<String, Object>>) table.get("columns");
|
||||
assertEquals(2, columns.size());
|
||||
assertEquals("a", columns.get(0).get("name"));
|
||||
assertEquals("b", columns.get(1).get("name"));
|
||||
|
||||
var rows = (java.util.List<java.util.Map<String, Object>>) table.get("rows");
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals("1", rows.get(0).get("a"));
|
||||
assertEquals("2", rows.get(0).get("b"));
|
||||
assertEquals("3", rows.get(1).get("a"));
|
||||
assertEquals("4", rows.get(1).get("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void render_jsonArrayManyColumns_usesDivPerItem() {
|
||||
// >4 字段 → 列表卡片(每条 item 一个 div)
|
||||
var card = FeishuCardFormatter.render(
|
||||
"[{\"a\":1,\"b\":2,\"c\":3,\"d\":4,\"e\":5}]", JSON);
|
||||
|
||||
var body = (java.util.Map<String, Object>) card.get("body");
|
||||
var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements");
|
||||
var div = elems.get(0);
|
||||
assertEquals("div", div.get("tag"));
|
||||
|
||||
var text = (java.util.Map<String, Object>) div.get("text");
|
||||
assertEquals("lark_md", text.get("tag"));
|
||||
String content = (String) text.get("content");
|
||||
assertTrue(content.contains("**a**:"), "content should contain **a**: field");
|
||||
assertTrue(content.contains("**b**:"), "content should contain **b**: field");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void render_plainText_fallsBackToLongTextLayout() {
|
||||
// PLAIN_TEXT 传入 render()("always" 模式下会发生)→ 应渲染为 plain_text div
|
||||
var card = FeishuCardFormatter.render("简单的一句话", PLAIN_TEXT);
|
||||
var body = (java.util.Map<String, Object>) card.get("body");
|
||||
var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements");
|
||||
var text = (java.util.Map<String, Object>) elems.get(0).get("text");
|
||||
assertEquals("plain_text", text.get("tag"));
|
||||
}
|
||||
|
||||
// ==================== detect() — Markdown 内嵌 JSON ====================
|
||||
|
||||
@Test
|
||||
void detect_markdownWithJsonObjectCodeBlock_returnsJson() {
|
||||
String md = "上海今天天气如下:\n\n```json\n{\"city\":\"上海\",\"temp\":24}\n```";
|
||||
assertEquals(JSON, FeishuCardFormatter.detect(md));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_markdownWithBareCodeBlockContainingJson_returnsJson() {
|
||||
// 无 json 标注的代码块,内容是 JSON 对象也识别
|
||||
String md = "结果:\n```\n{\"status\":\"ok\"}\n```";
|
||||
assertEquals(JSON, FeishuCardFormatter.detect(md));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_markdownWithJsonArrayCodeBlock_returnsJson() {
|
||||
String md = "列表:\n```json\n[{\"a\":1},{\"a\":2}]\n```";
|
||||
assertEquals(JSON, FeishuCardFormatter.detect(md));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_markdownWithPrimitiveArrayCodeBlock_returnsMarkdown() {
|
||||
// 原始类型数组不识别为 JSON
|
||||
String md = "数据:\n```json\n[1,2,3]\n```";
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect(md));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_markdownWithNonJsonCodeBlock_returnsMarkdown() {
|
||||
// Python 代码块不识别为 JSON
|
||||
String md = "代码:\n```python\nprint('hello')\n```";
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect(md));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_markdownWithEmptyJsonObjectCodeBlock_returnsMarkdown() {
|
||||
// 空对象 {} 不识别为 JSON
|
||||
String md = "空:\n```json\n{}\n```";
|
||||
assertEquals(MARKDOWN, FeishuCardFormatter.detect(md));
|
||||
}
|
||||
|
||||
// ==================== render() — Markdown 内嵌 JSON ====================
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void render_markdownWithJsonCodeBlock_rendersAsSummaryCard() {
|
||||
String md = "天气结果:\n```json\n{\"city\":\"上海\",\"temp\":24}\n```";
|
||||
var card = FeishuCardFormatter.render(md, JSON);
|
||||
|
||||
assertEquals("2.0", card.get("schema"));
|
||||
assertNull(card.get("header")); // JSON object card has no header
|
||||
var body = (java.util.Map<String, Object>) card.get("body");
|
||||
var elems = (java.util.List<java.util.Map<String, Object>>) body.get("elements");
|
||||
assertEquals(2, elems.size()); // 2 fields → 2 column_sets
|
||||
assertEquals("column_set", elems.get(0).get("tag"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_markdownWithJsonBlockSecond_returnsJson() {
|
||||
// JSON 对象代码块在原始数组块后面,应该仍能识别
|
||||
// 原始数组 [1,2,3] 不是有效 JSON,但 JSON 对象 {"ok":true} 是
|
||||
String md = "示例:\n```\n[1,2,3]\n```\n\n结果:\n```json\n{\"ok\":true,\"count\":5}\n```";
|
||||
assertEquals(JSON, FeishuCardFormatter.detect(md));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user