diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuCardFormatter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuCardFormatter.java new file mode 100644 index 00000000..37fc7f27 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuCardFormatter.java @@ -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 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 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 renderLongText(String content) { + return cardOf( + null, + List.of(Map.of( + "tag", "div", + "text", Map.of("tag", "plain_text", "content", content) + )) + ); + } + + private static Map 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 renderJsonObject(JsonNode node) { + List 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 renderJsonArray(JsonNode array) { + JsonNode first = array.get(0); + List fields = new ArrayList<>(); + first.fieldNames().forEachRemaining(fields::add); + return fields.size() <= 4 + ? renderJsonTable(array, fields) + : renderJsonList(array); + } + + private static Map renderJsonTable(JsonNode array, List fields) { + List> columns = fields.stream() + .>map(name -> Map.of("name", name, "display_name", name)) + .toList(); + List> rows = new ArrayList<>(); + for (JsonNode item : array) { + Map 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 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 renderJsonList(JsonNode array) { + List 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 cardOf(Map header, List elements) { + Map 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index f21f70e9..96d85a95 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -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 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 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 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 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 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 diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuCardFormatterTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuCardFormatterTest.java new file mode 100644 index 00000000..4c7fc133 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuCardFormatterTest.java @@ -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) card.get("body"); + var elems = (java.util.List>) body.get("elements"); + assertEquals("div", elems.get(0).get("tag")); + var text = (java.util.Map) 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) card.get("body"); + var elems = (java.util.List>) body.get("elements"); + var text = (java.util.Map) 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) card.get("body"); + var elems = (java.util.List>) 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) card.get("body"); + var elems = (java.util.List>) body.get("elements"); + var table = elems.get(0); + assertEquals("table", table.get("tag")); + + var columns = (java.util.List>) 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>) 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) card.get("body"); + var elems = (java.util.List>) body.get("elements"); + var div = elems.get(0); + assertEquals("div", div.get("tag")); + + var text = (java.util.Map) 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) card.get("body"); + var elems = (java.util.List>) body.get("elements"); + var text = (java.util.Map) 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) card.get("body"); + var elems = (java.util.List>) 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)); + } +}