飞书渠道简化
This commit is contained in:
parent
52925fcd73
commit
2bff987be1
@ -780,32 +780,11 @@ impl FeishuChannel {
|
||||
Some(format!("[Reply to: {}]", text))
|
||||
}
|
||||
|
||||
/// Send a message to Feishu chat with specified message type
|
||||
/// Send a message to Feishu chat with specified message type and content.
|
||||
/// Content is passed as-is (already a JSON string for file/media, or plain text for fallback).
|
||||
async fn send_message_to_feishu(&self, receive_id: &str, receive_id_type: &str, msg_type: &str, content: &str) -> Result<(), ChannelError> {
|
||||
let token = self.get_tenant_access_token().await?;
|
||||
|
||||
// Feishu text messages have content limits (~64KB).
|
||||
// Truncate if content is too long to avoid API error 230001.
|
||||
const MAX_TEXT_LENGTH: usize = 60_000;
|
||||
|
||||
let payload_content = if msg_type == "text" {
|
||||
let truncated = if content.len() > MAX_TEXT_LENGTH {
|
||||
format!("{}...\n\n[Content truncated due to length limit]", &content[..content.ceil_char_boundary(MAX_TEXT_LENGTH)])
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
serde_json::json!({ "text": truncated }).to_string()
|
||||
} else {
|
||||
// For post messages, content is already JSON (from markdown_to_post)
|
||||
// But we still need to check length
|
||||
if content.len() > MAX_TEXT_LENGTH {
|
||||
// Fallback to truncated text for post as well
|
||||
serde_json::json!({ "text": format!("{}...\n\n[Content truncated due to length limit]", &content[..content.ceil_char_boundary(MAX_TEXT_LENGTH)]) }).to_string()
|
||||
} else {
|
||||
content.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let resp = self.http_client
|
||||
.post(format!("{}/im/v1/messages?receive_id_type={}", FEISHU_API_BASE, receive_id_type))
|
||||
.header("Content-Type", "application/json")
|
||||
@ -813,7 +792,7 @@ impl FeishuChannel {
|
||||
.json(&serde_json::json!({
|
||||
"receive_id": receive_id,
|
||||
"msg_type": msg_type,
|
||||
"content": payload_content
|
||||
"content": content
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@ -1564,67 +1543,6 @@ fn strip_at_placeholders(text: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Markdown parsing and Feishu card element building
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Message format types for Feishu
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MsgFormat {
|
||||
/// Plain text, short and no markdown
|
||||
Text,
|
||||
/// Rich text (links only, moderate length)
|
||||
Post,
|
||||
/// Interactive card with full markdown rendering
|
||||
Interactive,
|
||||
}
|
||||
|
||||
/// Regex patterns for markdown detection
|
||||
struct MdPatterns {
|
||||
/// Regex to match markdown tables (header + separator + data rows)
|
||||
table_re: Regex,
|
||||
/// Regex to match headings (# heading)
|
||||
heading_re: Regex,
|
||||
/// Regex to match code blocks (```...```)
|
||||
code_block_re: Regex,
|
||||
/// Regex to match bold **text** or __text__
|
||||
bold_re: Regex,
|
||||
/// Regex to match italic *text* or _text_
|
||||
italic_re: Regex,
|
||||
/// Regex to match strikethrough ~~text~~
|
||||
strike_re: Regex,
|
||||
/// Regex to match markdown links [text](url)
|
||||
link_re: Regex,
|
||||
/// Regex to match unordered list items (- item or * item)
|
||||
list_re: Regex,
|
||||
/// Regex to match ordered list items (1. item)
|
||||
olist_re: Regex,
|
||||
}
|
||||
|
||||
impl MdPatterns {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
table_re: Regex::new(r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)").unwrap(),
|
||||
heading_re: Regex::new(r"^(#{1,6})\s+(.+)$").unwrap(),
|
||||
code_block_re: Regex::new(r"(```[\s\S]*?```)").unwrap(),
|
||||
bold_re: Regex::new(r"\*\*(.+?)\*\*|__(.+?)__").unwrap(),
|
||||
// Simple pattern: detect *text* or _text_ markers (may overlap with bold, but that's ok for detection)
|
||||
// We use a conservative approach: detect potential italic markers
|
||||
italic_re: Regex::new(r"\*[^*]+\*|_[^_]+_").unwrap(),
|
||||
strike_re: Regex::new(r"~~.+?~~").unwrap(),
|
||||
link_re: Regex::new(r"\[([^\]]+)\]\((https?://[^\)]+)\)").unwrap(),
|
||||
list_re: Regex::new(r"^[\s]*[-*+]\s+").unwrap(),
|
||||
olist_re: Regex::new(r"^[\s]*\d+\.\s+").unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MdPatterns {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FeishuChannel {
|
||||
fn strip_thinking_tags(content: &str) -> String {
|
||||
use std::sync::LazyLock;
|
||||
@ -1635,328 +1553,69 @@ impl FeishuChannel {
|
||||
stripped.trim().to_string()
|
||||
}
|
||||
|
||||
/// Determine the optimal Feishu message format for content.
|
||||
fn detect_msg_format(content: &str) -> MsgFormat {
|
||||
let patterns = MdPatterns::new();
|
||||
let stripped = content.trim();
|
||||
|
||||
// Complex markdown (code blocks, tables, headings) → always interactive card
|
||||
if patterns.table_re.is_match(stripped)
|
||||
|| patterns.heading_re.is_match(stripped)
|
||||
|| patterns.code_block_re.is_match(stripped)
|
||||
{
|
||||
return MsgFormat::Interactive;
|
||||
}
|
||||
|
||||
// Long content → interactive card (better readability)
|
||||
if stripped.len() > 2000 {
|
||||
return MsgFormat::Interactive;
|
||||
}
|
||||
|
||||
// Has bold/italic/strikethrough → interactive card (post format can't render these)
|
||||
if patterns.bold_re.is_match(stripped)
|
||||
|| patterns.italic_re.is_match(stripped)
|
||||
|| patterns.strike_re.is_match(stripped)
|
||||
{
|
||||
return MsgFormat::Interactive;
|
||||
}
|
||||
|
||||
// Has list items → interactive card (post format can't render list bullets well)
|
||||
if patterns.list_re.is_match(stripped) || patterns.olist_re.is_match(stripped) {
|
||||
return MsgFormat::Interactive;
|
||||
}
|
||||
|
||||
// Has links → post format (supports <a> tags)
|
||||
if patterns.link_re.is_match(stripped) {
|
||||
return MsgFormat::Post;
|
||||
}
|
||||
|
||||
// Short plain text → text format
|
||||
if stripped.len() <= 200 {
|
||||
return MsgFormat::Text;
|
||||
}
|
||||
|
||||
// Medium plain text without formatting → post format
|
||||
MsgFormat::Post
|
||||
}
|
||||
|
||||
/// Strip markdown formatting markers from text for plain display.
|
||||
fn strip_md_formatting(text: &str) -> String {
|
||||
let patterns = MdPatterns::new();
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove bold markers
|
||||
result = patterns.bold_re.replace_all(&result, "$1$2").to_string();
|
||||
// Remove italic markers
|
||||
result = patterns.italic_re.replace_all(&result, "$1$2").to_string();
|
||||
// Remove strikethrough markers
|
||||
result = patterns.strike_re.replace_all(&result, "$1").to_string();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse a markdown table into a Feishu table element.
|
||||
fn parse_md_table(table_text: &str) -> Option<serde_json::Value> {
|
||||
let lines: Vec<&str> = table_text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
if lines.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
fn split(line: &str) -> Vec<String> {
|
||||
line.trim_start_matches('|')
|
||||
.trim_end_matches('|')
|
||||
.split('|')
|
||||
.map(|c| c.trim().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
let headers = split(lines[0]);
|
||||
let rows: Vec<std::collections::HashMap<String, String>> = lines[2..]
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let cells: Vec<String> = split(line);
|
||||
headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _h)| {
|
||||
(
|
||||
format!("c{}", i),
|
||||
if i < cells.len() {
|
||||
Self::strip_md_formatting(&cells[i])
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let columns: Vec<serde_json::Value> = headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| {
|
||||
/// Build a Card JSON 2.0 interactive card with a single markdown element.
|
||||
fn build_card_content(markdown: &str) -> String {
|
||||
serde_json::json!({
|
||||
"tag": "column",
|
||||
"name": format!("c{}", i),
|
||||
"display_name": Self::strip_md_formatting(h),
|
||||
"width": "auto"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(serde_json::json!({
|
||||
"tag": "table",
|
||||
"page_size": rows.len() + 1,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Split content by headings, converting headings to div elements.
|
||||
fn split_headings(content: &str) -> Vec<serde_json::Value> {
|
||||
let patterns = MdPatterns::new();
|
||||
let mut protected = content.to_string();
|
||||
|
||||
// Protect code blocks by replacing them with placeholders
|
||||
let mut code_blocks: Vec<String> = Vec::new();
|
||||
for m in patterns.code_block_re.find_iter(content) {
|
||||
code_blocks.push(m.as_str().to_string());
|
||||
protected = protected.replace(m.as_str(), &format!("\x00CODE{}\x00", code_blocks.len() - 1));
|
||||
}
|
||||
|
||||
let mut elements: Vec<serde_json::Value> = Vec::new();
|
||||
let mut last_end = 0;
|
||||
|
||||
for m in patterns.heading_re.find_iter(&protected) {
|
||||
let before = &protected[last_end..m.start()].trim();
|
||||
if !before.is_empty() {
|
||||
elements.push(serde_json::json!({
|
||||
"schema": "2.0",
|
||||
"body": {
|
||||
"elements": [{
|
||||
"tag": "markdown",
|
||||
"content": before
|
||||
}));
|
||||
"content": markdown
|
||||
}]
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
let heading_text = Self::strip_md_formatting(m.as_str().trim_start_matches('#').trim());
|
||||
let display_text = if heading_text.is_empty() {
|
||||
String::new()
|
||||
/// Max byte-size for markdown content in a single card.
|
||||
/// Card payloads have a ~30 KB limit; leave margin for JSON envelope.
|
||||
const CARD_MARKDOWN_MAX_BYTES: usize = 28_000;
|
||||
|
||||
/// Split markdown content into chunks that fit within the card size limit.
|
||||
/// Splits on line boundaries to avoid breaking markdown syntax.
|
||||
fn split_markdown_chunks(text: &str) -> Vec<String> {
|
||||
if text.len() <= Self::CARD_MARKDOWN_MAX_BYTES {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut chunks: Vec<String> = Vec::new();
|
||||
let mut start = 0;
|
||||
|
||||
while start < text.len() {
|
||||
if start + Self::CARD_MARKDOWN_MAX_BYTES >= text.len() {
|
||||
chunks.push(text[start..].to_string());
|
||||
break;
|
||||
}
|
||||
|
||||
let end = start + Self::CARD_MARKDOWN_MAX_BYTES;
|
||||
let search_region = &text[start..end];
|
||||
let split_at = search_region
|
||||
.rfind('\n')
|
||||
.map(|pos| start + pos + 1)
|
||||
.unwrap_or(end);
|
||||
|
||||
let split_at = if text.is_char_boundary(split_at) {
|
||||
split_at
|
||||
} else {
|
||||
format!("**{}**", heading_text)
|
||||
(start..split_at)
|
||||
.rev()
|
||||
.find(|&i| text.is_char_boundary(i))
|
||||
.unwrap_or(start)
|
||||
};
|
||||
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": display_text
|
||||
}
|
||||
}));
|
||||
|
||||
last_end = m.end();
|
||||
}
|
||||
|
||||
let remaining = protected[last_end..].trim();
|
||||
if !remaining.is_empty() {
|
||||
// Restore code blocks
|
||||
let mut final_content = remaining.to_string();
|
||||
for (i, cb) in code_blocks.iter().enumerate() {
|
||||
final_content = final_content.replace(&format!("\x00CODE{}\x00", i), cb);
|
||||
}
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "markdown",
|
||||
"content": final_content
|
||||
}));
|
||||
}
|
||||
|
||||
if elements.is_empty() {
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "markdown",
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
|
||||
elements
|
||||
}
|
||||
|
||||
/// Split card elements into groups with at most one table element each.
|
||||
/// Feishu cards have a hard limit of one table per card (API error 11310).
|
||||
fn split_elements_by_table_limit(elements: &[serde_json::Value]) -> Vec<Vec<serde_json::Value>> {
|
||||
if elements.is_empty() {
|
||||
return vec![vec![]];
|
||||
}
|
||||
|
||||
let mut groups: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
let mut current: Vec<serde_json::Value> = Vec::new();
|
||||
let mut table_count = 0;
|
||||
|
||||
for el in elements {
|
||||
if el.get("tag").and_then(|t| t.as_str()) == Some("table") {
|
||||
if table_count >= 1 {
|
||||
groups.push(current);
|
||||
current = Vec::new();
|
||||
table_count = 0;
|
||||
}
|
||||
current.push(el.clone());
|
||||
table_count += 1;
|
||||
if split_at <= start {
|
||||
let forced = (end..=text.len())
|
||||
.find(|&i| text.is_char_boundary(i))
|
||||
.unwrap_or(text.len());
|
||||
chunks.push(text[start..forced].to_string());
|
||||
start = forced;
|
||||
} else {
|
||||
current.push(el.clone());
|
||||
chunks.push(text[start..split_at].to_string());
|
||||
start = split_at;
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
groups.push(current);
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// Build content into card elements (div/markdown + table).
|
||||
fn build_card_elements(content: &str) -> Vec<serde_json::Value> {
|
||||
let patterns = MdPatterns::new();
|
||||
let mut elements: Vec<serde_json::Value> = Vec::new();
|
||||
let mut last_end = 0;
|
||||
|
||||
// Find all tables in content
|
||||
for m in patterns.table_re.find_iter(content) {
|
||||
let before = &content[last_end..m.start()];
|
||||
if !before.trim().is_empty() {
|
||||
elements.extend(Self::split_headings(before));
|
||||
}
|
||||
|
||||
if let Some(table) = Self::parse_md_table(m.as_str()) {
|
||||
elements.push(table);
|
||||
} else {
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "markdown",
|
||||
"content": m.as_str()
|
||||
}));
|
||||
}
|
||||
|
||||
last_end = m.end();
|
||||
}
|
||||
|
||||
let remaining = &content[last_end..];
|
||||
if !remaining.trim().is_empty() {
|
||||
elements.extend(Self::split_headings(remaining));
|
||||
}
|
||||
|
||||
if elements.is_empty() {
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "markdown",
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
|
||||
elements
|
||||
}
|
||||
|
||||
/// Convert markdown content to Feishu post message JSON.
|
||||
/// Handles links [text](url) as <a> tags; everything else as text tags.
|
||||
fn markdown_to_post(content: &str) -> String {
|
||||
let patterns = MdPatterns::new();
|
||||
let lines = content.trim().split('\n');
|
||||
let mut paragraphs: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let mut elements: Vec<serde_json::Value> = Vec::new();
|
||||
let mut last_end = 0;
|
||||
|
||||
for cap in patterns.link_re.captures_iter(line) {
|
||||
// Text before this link
|
||||
let m = cap.get(0).unwrap();
|
||||
let before = &line[last_end..m.start()];
|
||||
if !before.is_empty() {
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "text",
|
||||
"text": before
|
||||
}));
|
||||
}
|
||||
|
||||
let link_text = cap.get(1).map(|g| g.as_str()).unwrap_or("");
|
||||
let link_href = cap.get(2).map(|g| g.as_str()).unwrap_or("");
|
||||
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "a",
|
||||
"text": link_text,
|
||||
"href": link_href
|
||||
}));
|
||||
|
||||
last_end = m.end();
|
||||
}
|
||||
|
||||
// Remaining text after last link
|
||||
let remaining = &line[last_end..];
|
||||
if !remaining.is_empty() {
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "text",
|
||||
"text": remaining
|
||||
}));
|
||||
}
|
||||
|
||||
// Empty line → empty paragraph for spacing
|
||||
if elements.is_empty() {
|
||||
elements.push(serde_json::json!({
|
||||
"tag": "text",
|
||||
"text": ""
|
||||
}));
|
||||
}
|
||||
|
||||
paragraphs.push(elements);
|
||||
}
|
||||
|
||||
let post_body = serde_json::json!({
|
||||
"zh_cn": {
|
||||
"content": paragraphs
|
||||
}
|
||||
});
|
||||
|
||||
post_body.to_string()
|
||||
chunks
|
||||
}
|
||||
|
||||
/// Send an interactive card message to Feishu.
|
||||
@ -2097,7 +1756,7 @@ impl Channel for FeishuChannel {
|
||||
let receive_id = if msg.chat_id.starts_with("oc_") { &msg.chat_id } else { msg.reply_to.as_ref().unwrap_or(&msg.chat_id) };
|
||||
let receive_id_type = if msg.chat_id.starts_with("oc_") { "chat_id" } else { "open_id" };
|
||||
|
||||
// If no media, use smart format detection
|
||||
// If no media, send as interactive card with raw markdown
|
||||
if msg.media.is_empty() {
|
||||
let content = msg.content.trim();
|
||||
|
||||
@ -2107,34 +1766,13 @@ impl Channel for FeishuChannel {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let fmt = Self::detect_msg_format(content);
|
||||
|
||||
match fmt {
|
||||
MsgFormat::Text => {
|
||||
// Short plain text – send as simple text message
|
||||
let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", content).await;
|
||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||
return result;
|
||||
}
|
||||
MsgFormat::Post => {
|
||||
// Medium content with links – send as rich-text post
|
||||
let post_body = Self::markdown_to_post(content);
|
||||
let result = self.send_message_to_feishu(receive_id, receive_id_type, "post", &post_body).await;
|
||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||
return result;
|
||||
}
|
||||
MsgFormat::Interactive => {
|
||||
// Complex / long content – send as interactive card
|
||||
let elements = Self::build_card_elements(content);
|
||||
for chunk in Self::split_elements_by_table_limit(&elements) {
|
||||
let card = serde_json::json!({
|
||||
"config": { "wide_screen_mode": true },
|
||||
"elements": chunk
|
||||
});
|
||||
if let Err(e) = self.send_interactive_card(receive_id, receive_id_type, &card.to_string()).await {
|
||||
let chunks = Self::split_markdown_chunks(content);
|
||||
for chunk in &chunks {
|
||||
let card = Self::build_card_content(chunk);
|
||||
if let Err(e) = self.send_interactive_card(receive_id, receive_id_type, &card).await {
|
||||
tracing::warn!(error = %e, "Failed to send interactive card, falling back to text");
|
||||
// Fallback to plain text
|
||||
let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", content).await;
|
||||
let text_content = serde_json::json!({ "text": chunk }).to_string();
|
||||
let result = self.send_message_to_feishu(receive_id, receive_id_type, "text", &text_content).await;
|
||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||
return result;
|
||||
}
|
||||
@ -2142,8 +1780,6 @@ impl Channel for FeishuChannel {
|
||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multimodal message - send with media
|
||||
let token = self.get_tenant_access_token().await?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user