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:
倪程伟 2026-05-20 08:42:02 +08:00 committed by GitHub
parent db16ff02a5
commit ee0c229f52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 553 additions and 6 deletions

View File

@ -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;
}
}

View File

@ -52,6 +52,8 @@ import java.util.concurrent.TimeUnit;
* - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt默认 true
* - silent_disconnect_threshold_seconds: WebSocket 静默断连阈值默认 18000 禁用
* - stale_event_threshold_seconds: 过滤旧事件阈值默认 300 禁用
* - 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

View File

@ -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));
}
}