fix(feishu): preserve reply and thread semantics
This commit is contained in:
parent
0512d91729
commit
7e53fa658b
@ -243,7 +243,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
|
|||||||
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
|
| `cli_chat` | Ratatui 终端客户端,通过 WebSocket 连接 Gateway |
|
||||||
| `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 |
|
| `feishu` | 飞书/Lark 消息、反应、文件上传下载和媒体引用 |
|
||||||
|
|
||||||
飞书默认只接受 `allow_from` 中的用户,且群聊消息必须明确 @ 机器人(可通过 `channels.feishu.require_mention=false` 关闭)。默认只发送终态结果;设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑,`live_update_interval_ms` 默认 500ms,运行时限制在 250–5000ms。外部渠道始终不会收到模型 reasoning。
|
飞书默认只接受 `allow_from` 中的用户,且群聊消息必须明确 @ 机器人(可通过 `channels.feishu.require_mention=false` 关闭)。回复会使用飞书原生引用/话题语义保持在原消息位置。默认只发送终态结果;设置 `channels.feishu.live_updates=true` 后会创建一张卡片并持续编辑,`live_update_interval_ms` 默认 500ms,运行时限制在 250–5000ms。外部渠道始终不会收到模型 reasoning。
|
||||||
|
|
||||||
### 会话
|
### 会话
|
||||||
|
|
||||||
|
|||||||
@ -125,7 +125,7 @@ sequenceDiagram
|
|||||||
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
|
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
|
||||||
- 队列满时明确拒绝新消息,不允许无界积压。
|
- 队列满时明确拒绝新消息,不允许无界积压。
|
||||||
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
|
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
|
||||||
- `InboundMessage` 只保存规范化输入:`sender_id`、`received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`;reaction/message ID 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。
|
- `InboundMessage` 只保存规范化输入:`sender_id`、`received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`,其语义是本轮出站应回复的当前入站消息;被用户引用的父消息只用于补充模型上下文。reaction/message ID、话题 root/thread 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。
|
||||||
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
|
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
|
||||||
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
||||||
- Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial;只有 reasoning 时不创建 assistant 历史。
|
- Agent 本轮消息原子持久化成功后才发布 `Completed`。取消或失败若已有可见正文,则保存为 `cancelled`/`interrupted` partial;只有 reasoning 时不创建 assistant 历史。
|
||||||
@ -140,7 +140,7 @@ sequenceDiagram
|
|||||||
- `TurnDeliveryService` 返回可等待的终态句柄;sink 生命周期启动不等于终态已送达。Session 在终态重试最终失败时通过普通出站路径兜底一次。
|
- `TurnDeliveryService` 返回可等待的终态句柄;sink 生命周期启动不等于终态已送达。Session 在终态重试最终失败时通过普通出站路径兜底一次。
|
||||||
- `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。
|
- `cli_chat` 将同一 `turn_updated` 快照发给 TUI 和 WebUI。客户端只保留当前 session 中 revision 更新的 `active_turn`,终态随后由持久化历史校准。
|
||||||
- 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK,再进行有界分片重组,并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。
|
- 飞书对每个 DATA 帧先在 2 秒硬期限内 ACK,再进行有界分片重组,并把完整事件交给容量 32 的连接内处理队列;媒体下载和引用查询不占用正常的 WebSocket 读循环。队列饱和时当前事件在连接任务中同步处理而不丢弃。连接异常采用有上限的指数退避持续重连,不因累计故障永久停止。
|
||||||
- 飞书在协议解析阶段按 `allow_from` 拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书默认 `FinalOnly`;开启 `live_updates` 后,第一个可见快照创建卡片,后续编辑同一卡片,终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
|
- 飞书在协议解析阶段按 `allow_from` 拒绝未授权用户;群聊默认必须明确 @ 运行时解析出的机器人身份,身份解析失败时安全地忽略群消息。飞书把当前消息 ID 作为 `reply_to`,并在私有 metadata 中携带 root/thread 信息;Sink 使用原生 reply API 及 `reply_in_thread` 保持客户端引用和话题位置。飞书默认 `FinalOnly`;开启 `live_updates` 后,第一个可见快照创建卡片,后续编辑同一卡片,终态编辑失败则发送完整结果兜底。reaction 清理在 finish、abort 和 Gateway shutdown 中幂等执行。
|
||||||
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
|
- DeliveryCoordinator 与 OutboundDispatcher 共享 `(channel, chat_id)` 写锁,避免活动 Turn 终态与独立消息并发写入同一目标。
|
||||||
|
|
||||||
### 出站投递
|
### 出站投递
|
||||||
|
|||||||
@ -159,6 +159,10 @@ struct LarkMessage {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
root_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
mentions: Vec<serde_json::Value>,
|
mentions: Vec<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,6 +201,77 @@ struct ParsedMessage {
|
|||||||
/// ID of the message this message is replying to (if any).
|
/// ID of the message this message is replying to (if any).
|
||||||
/// Used to fetch quoted message content for display.
|
/// Used to fetch quoted message content for display.
|
||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
|
root_id: Option<String>,
|
||||||
|
thread_id: Option<String>,
|
||||||
|
chat_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct FeishuSendTarget {
|
||||||
|
receive_id: String,
|
||||||
|
receive_id_type: &'static str,
|
||||||
|
reply_to: Option<String>,
|
||||||
|
reply_in_thread: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeishuSendTarget {
|
||||||
|
fn from_message(
|
||||||
|
chat_id: String,
|
||||||
|
reply_to: Option<String>,
|
||||||
|
metadata: &HashMap<String, String>,
|
||||||
|
) -> Self {
|
||||||
|
let thread_id = metadata
|
||||||
|
.get("feishu.thread_id")
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
if reply_to.is_none()
|
||||||
|
&& let Some(thread_id) = thread_id
|
||||||
|
{
|
||||||
|
return Self {
|
||||||
|
receive_id: thread_id.clone(),
|
||||||
|
receive_id_type: "thread_id",
|
||||||
|
reply_to: None,
|
||||||
|
reply_in_thread: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let receive_id_type = if chat_id.starts_with("oc_") {
|
||||||
|
"chat_id"
|
||||||
|
} else {
|
||||||
|
"open_id"
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
receive_id: chat_id,
|
||||||
|
receive_id_type,
|
||||||
|
reply_to,
|
||||||
|
reply_in_thread: thread_id.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, msg_type: &str, content: &str) -> (String, serde_json::Value) {
|
||||||
|
let uuid = uuid::Uuid::new_v4().to_string();
|
||||||
|
if let Some(message_id) = &self.reply_to {
|
||||||
|
return (
|
||||||
|
format!("{}/im/v1/messages/{message_id}/reply", FEISHU_API_BASE),
|
||||||
|
serde_json::json!({
|
||||||
|
"msg_type": msg_type,
|
||||||
|
"content": content,
|
||||||
|
"reply_in_thread": self.reply_in_thread,
|
||||||
|
"uuid": uuid
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
"{}/im/v1/messages?receive_id_type={}",
|
||||||
|
FEISHU_API_BASE, self.receive_id_type
|
||||||
|
),
|
||||||
|
serde_json::json!({
|
||||||
|
"receive_id": self.receive_id,
|
||||||
|
"msg_type": msg_type,
|
||||||
|
"content": content,
|
||||||
|
"uuid": uuid
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeishuChannel {
|
impl FeishuChannel {
|
||||||
@ -733,7 +808,10 @@ impl FeishuChannel {
|
|||||||
/// Add a reaction emoji to a message and store the reaction_id for later removal.
|
/// Add a reaction emoji to a message and store the reaction_id for later removal.
|
||||||
/// Returns the reaction_id if successful, None otherwise.
|
/// Returns the reaction_id if successful, None otherwise.
|
||||||
async fn add_reaction(&self, message_id: &str) -> Result<Option<String>, ChannelError> {
|
async fn add_reaction(&self, message_id: &str) -> Result<Option<String>, ChannelError> {
|
||||||
let emoji = self.config.reaction_emoji.as_str();
|
let emoji = self.config.reaction_emoji.trim();
|
||||||
|
if emoji.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let token = self.get_tenant_access_token().await?;
|
let token = self.get_tenant_access_token().await?;
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
@ -923,7 +1001,10 @@ impl FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text = if text.len() > Self::REPLY_CONTEXT_MAX_LEN {
|
let text = if text.len() > Self::REPLY_CONTEXT_MAX_LEN {
|
||||||
format!("{}...", &text[..Self::REPLY_CONTEXT_MAX_LEN])
|
format!(
|
||||||
|
"{}...",
|
||||||
|
&text[..text.floor_char_boundary(Self::REPLY_CONTEXT_MAX_LEN)]
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
text
|
text
|
||||||
};
|
};
|
||||||
@ -931,30 +1012,22 @@ impl FeishuChannel {
|
|||||||
Some(format!("[Reply to: {}]", text))
|
Some(format!("[Reply to: {}]", text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a message to Feishu chat with specified message type and content.
|
/// Deliver one native Feishu message using create or reply semantics.
|
||||||
/// Content is passed as-is (already a JSON string for file/media, or plain text for fallback).
|
async fn send_raw_message(
|
||||||
async fn send_message_to_feishu(
|
|
||||||
&self,
|
&self,
|
||||||
receive_id: &str,
|
target: &FeishuSendTarget,
|
||||||
receive_id_type: &str,
|
|
||||||
msg_type: &str,
|
msg_type: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<String, ChannelError> {
|
||||||
let token = self.get_tenant_access_token().await?;
|
let token = self.get_tenant_access_token().await?;
|
||||||
|
let (url, body) = target.request(msg_type, content);
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.http_client
|
.http_client
|
||||||
.post(format!(
|
.post(url)
|
||||||
"{}/im/v1/messages?receive_id_type={}",
|
|
||||||
FEISHU_API_BASE, receive_id_type
|
|
||||||
))
|
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&serde_json::json!({
|
.json(&body)
|
||||||
"receive_id": receive_id,
|
|
||||||
"msg_type": msg_type,
|
|
||||||
"content": content
|
|
||||||
}))
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@ -965,6 +1038,11 @@ impl FeishuChannel {
|
|||||||
struct SendResp {
|
struct SendResp {
|
||||||
code: i32,
|
code: i32,
|
||||||
msg: String,
|
msg: String,
|
||||||
|
data: Option<SendData>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SendData {
|
||||||
|
message_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
let send_resp: SendResp = resp
|
let send_resp: SendResp = resp
|
||||||
@ -972,6 +1050,42 @@ impl FeishuChannel {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ChannelError::Other(format!("Parse send response error: {}", e)))?;
|
.map_err(|e| ChannelError::Other(format!("Parse send response error: {}", e)))?;
|
||||||
|
|
||||||
|
if matches!(send_resp.code, 230011 | 231003)
|
||||||
|
&& target.reply_to.is_some()
|
||||||
|
&& !target.reply_in_thread
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
code = send_resp.code,
|
||||||
|
"Feishu reply target is unavailable; falling back to a new chat message"
|
||||||
|
);
|
||||||
|
let mut create_target = target.clone();
|
||||||
|
create_target.reply_to = None;
|
||||||
|
let (url, body) = create_target.request(msg_type, content);
|
||||||
|
let response = self
|
||||||
|
.http_client
|
||||||
|
.post(url)
|
||||||
|
.bearer_auth(self.get_tenant_access_token().await?)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
ChannelError::ConnectionError(format!(
|
||||||
|
"Fallback send message HTTP error: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let fallback: SendResp = response.json().await.map_err(|error| {
|
||||||
|
ChannelError::Other(format!("Parse fallback send response error: {error}"))
|
||||||
|
})?;
|
||||||
|
if fallback.code != 0 {
|
||||||
|
return Err(ChannelError::Other(format!(
|
||||||
|
"Fallback send failed: code={} msg={}",
|
||||||
|
fallback.code, fallback.msg
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(fallback
|
||||||
|
.data
|
||||||
|
.map_or_else(String::new, |data| data.message_id));
|
||||||
|
}
|
||||||
if send_resp.code != 0 {
|
if send_resp.code != 0 {
|
||||||
return Err(ChannelError::Other(format!(
|
return Err(ChannelError::Other(format!(
|
||||||
"Send message failed: code={} msg={}",
|
"Send message failed: code={} msg={}",
|
||||||
@ -979,7 +1093,9 @@ impl FeishuChannel {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(send_resp
|
||||||
|
.data
|
||||||
|
.map_or_else(String::new, |data| data.message_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract service_id from WebSocket URL query params
|
/// Extract service_id from WebSocket URL query params
|
||||||
@ -1069,6 +1185,9 @@ impl FeishuChannel {
|
|||||||
let msg_type = msg.message_type.as_str();
|
let msg_type = msg.message_type.as_str();
|
||||||
let raw_content = msg.content.clone();
|
let raw_content = msg.content.clone();
|
||||||
let parent_id = msg.parent_id.clone();
|
let parent_id = msg.parent_id.clone();
|
||||||
|
let root_id = msg.root_id.clone();
|
||||||
|
let thread_id = msg.thread_id.clone().or_else(|| root_id.clone());
|
||||||
|
let chat_type = msg.chat_type.clone();
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(msg_type = %msg_type, chat_id = %chat_id, open_id = %open_id, "Parsing message content");
|
tracing::debug!(msg_type = %msg_type, chat_id = %chat_id, open_id = %open_id, "Parsing message content");
|
||||||
@ -1101,6 +1220,9 @@ impl FeishuChannel {
|
|||||||
content,
|
content,
|
||||||
media,
|
media,
|
||||||
parent_id,
|
parent_id,
|
||||||
|
root_id,
|
||||||
|
thread_id,
|
||||||
|
chat_type,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1244,7 +1366,17 @@ impl FeishuChannel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut private_context = HashMap::new();
|
let mut private_context = HashMap::new();
|
||||||
private_context.insert("feishu.message_id".to_string(), message_id);
|
private_context.insert("feishu.message_id".to_string(), message_id.clone());
|
||||||
|
private_context.insert("feishu.chat_type".to_string(), parsed.chat_type);
|
||||||
|
if let Some(parent_id) = parsed.parent_id.as_ref() {
|
||||||
|
private_context.insert("feishu.parent_id".to_string(), parent_id.clone());
|
||||||
|
}
|
||||||
|
if let Some(root_id) = parsed.root_id {
|
||||||
|
private_context.insert("feishu.root_id".to_string(), root_id);
|
||||||
|
}
|
||||||
|
if let Some(thread_id) = parsed.thread_id {
|
||||||
|
private_context.insert("feishu.thread_id".to_string(), thread_id);
|
||||||
|
}
|
||||||
if let Some(reaction_id) = reaction_id {
|
if let Some(reaction_id) = reaction_id {
|
||||||
private_context.insert("feishu.reaction_id".to_string(), reaction_id);
|
private_context.insert("feishu.reaction_id".to_string(), reaction_id);
|
||||||
}
|
}
|
||||||
@ -1257,7 +1389,7 @@ impl FeishuChannel {
|
|||||||
received_at: crate::bus::message::current_timestamp(),
|
received_at: crate::bus::message::current_timestamp(),
|
||||||
media: parsed.media,
|
media: parsed.media,
|
||||||
channel_context: crate::bus::ChannelContext {
|
channel_context: crate::bus::ChannelContext {
|
||||||
reply_to: parsed.parent_id,
|
reply_to: Some(message_id),
|
||||||
private: private_context,
|
private: private_context,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -2144,59 +2276,19 @@ impl FeishuChannel {
|
|||||||
/// Send an interactive card message to Feishu.
|
/// Send an interactive card message to Feishu.
|
||||||
async fn send_interactive_card(
|
async fn send_interactive_card(
|
||||||
&self,
|
&self,
|
||||||
receive_id: &str,
|
target: &FeishuSendTarget,
|
||||||
receive_id_type: &str,
|
|
||||||
card_content: &str,
|
card_content: &str,
|
||||||
) -> Result<String, ChannelError> {
|
) -> Result<String, ChannelError> {
|
||||||
let token = self.get_tenant_access_token().await?;
|
let message_id = self
|
||||||
|
.send_raw_message(target, "interactive", card_content)
|
||||||
let resp = self
|
.await?;
|
||||||
.http_client
|
if message_id.is_empty() {
|
||||||
.post(format!(
|
Err(ChannelError::Other(
|
||||||
"{}/im/v1/messages?receive_id_type={}",
|
"Feishu send response has no message_id".into(),
|
||||||
FEISHU_API_BASE, receive_id_type
|
|
||||||
))
|
))
|
||||||
.header("Content-Type", "application/json")
|
} else {
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
Ok(message_id)
|
||||||
.json(&serde_json::json!({
|
|
||||||
"receive_id": receive_id,
|
|
||||||
"msg_type": "interactive",
|
|
||||||
"content": card_content
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
ChannelError::ConnectionError(format!("Send interactive card HTTP error: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SendResp {
|
|
||||||
code: i32,
|
|
||||||
msg: String,
|
|
||||||
data: Option<SendData>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SendData {
|
|
||||||
message_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let send_resp: SendResp = resp.json().await.map_err(|e| {
|
|
||||||
ChannelError::Other(format!("Parse send interactive card response error: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if send_resp.code != 0 {
|
|
||||||
return Err(ChannelError::Other(format!(
|
|
||||||
"Send interactive card failed: code={} msg={}",
|
|
||||||
send_resp.code, send_resp.msg
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
send_resp
|
|
||||||
.data
|
|
||||||
.map(|data| data.message_id)
|
|
||||||
.filter(|message_id| !message_id.is_empty())
|
|
||||||
.ok_or_else(|| ChannelError::Other("Feishu send response has no message_id".into()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_interactive_card(
|
async fn update_interactive_card(
|
||||||
@ -2246,8 +2338,7 @@ trait FeishuTurnApi: Send {
|
|||||||
|
|
||||||
struct FeishuTurnBackend {
|
struct FeishuTurnBackend {
|
||||||
channel: FeishuChannel,
|
channel: FeishuChannel,
|
||||||
receive_id: String,
|
target: FeishuSendTarget,
|
||||||
receive_id_type: &'static str,
|
|
||||||
metadata: HashMap<String, String>,
|
metadata: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2256,7 +2347,7 @@ impl FeishuTurnApi for FeishuTurnBackend {
|
|||||||
async fn create_card(&mut self, markdown: &str) -> Result<String, ChannelError> {
|
async fn create_card(&mut self, markdown: &str) -> Result<String, ChannelError> {
|
||||||
let card = FeishuChannel::build_card_content(markdown);
|
let card = FeishuChannel::build_card_content(markdown);
|
||||||
self.channel
|
self.channel
|
||||||
.send_interactive_card(&self.receive_id, self.receive_id_type, &card)
|
.send_interactive_card(&self.target, &card)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2587,30 +2678,21 @@ impl Channel for FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn open_turn(&self, target: TurnTarget) -> Result<Box<dyn TurnSink>, ChannelError> {
|
async fn open_turn(&self, target: TurnTarget) -> Result<Box<dyn TurnSink>, ChannelError> {
|
||||||
let (receive_id, receive_id_type) = if target.chat_id.starts_with("oc_") {
|
let send_target =
|
||||||
(target.chat_id, "chat_id")
|
FeishuSendTarget::from_message(target.chat_id, target.reply_to, &target.metadata);
|
||||||
} else {
|
|
||||||
(target.reply_to.unwrap_or(target.chat_id), "open_id")
|
|
||||||
};
|
|
||||||
Ok(Box::new(FeishuTurnSink::new(Box::new(FeishuTurnBackend {
|
Ok(Box::new(FeishuTurnSink::new(Box::new(FeishuTurnBackend {
|
||||||
channel: self.clone(),
|
channel: self.clone(),
|
||||||
receive_id,
|
target: send_target,
|
||||||
receive_id_type,
|
|
||||||
metadata: target.metadata,
|
metadata: target.metadata,
|
||||||
}))))
|
}))))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
||||||
let receive_id = if msg.chat_id.starts_with("oc_") {
|
let target = FeishuSendTarget::from_message(
|
||||||
&msg.chat_id
|
msg.chat_id.clone(),
|
||||||
} else {
|
msg.reply_to.clone(),
|
||||||
msg.reply_to.as_ref().unwrap_or(&msg.chat_id)
|
&msg.metadata,
|
||||||
};
|
);
|
||||||
let receive_id_type = if msg.chat_id.starts_with("oc_") {
|
|
||||||
"chat_id"
|
|
||||||
} else {
|
|
||||||
"open_id"
|
|
||||||
};
|
|
||||||
|
|
||||||
// If no media, send as interactive card with raw markdown
|
// If no media, send as interactive card with raw markdown
|
||||||
if msg.media.is_empty() {
|
if msg.media.is_empty() {
|
||||||
@ -2625,15 +2707,13 @@ impl Channel for FeishuChannel {
|
|||||||
let chunks = Self::split_markdown_chunks(content);
|
let chunks = Self::split_markdown_chunks(content);
|
||||||
for chunk in &chunks {
|
for chunk in &chunks {
|
||||||
let card = Self::build_card_content(chunk);
|
let card = Self::build_card_content(chunk);
|
||||||
if let Err(e) = self
|
if let Err(e) = self.send_interactive_card(&target, &card).await {
|
||||||
.send_interactive_card(receive_id, receive_id_type, &card)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "Failed to send interactive card, falling back to text");
|
tracing::warn!(error = %e, "Failed to send interactive card, falling back to text");
|
||||||
let text_content = serde_json::json!({ "text": chunk }).to_string();
|
let text_content = serde_json::json!({ "text": chunk }).to_string();
|
||||||
let result = self
|
let result = self
|
||||||
.send_message_to_feishu(receive_id, receive_id_type, "text", &text_content)
|
.send_raw_message(&target, "text", &text_content)
|
||||||
.await;
|
.await
|
||||||
|
.map(|_| ());
|
||||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -2643,8 +2723,6 @@ impl Channel for FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle multimodal message - send with media
|
// Handle multimodal message - send with media
|
||||||
let token = self.get_tenant_access_token().await?;
|
|
||||||
|
|
||||||
// Separate images (can embed in post) from files (sent as separate messages)
|
// Separate images (can embed in post) from files (sent as separate messages)
|
||||||
let mut image_items = Vec::new();
|
let mut image_items = Vec::new();
|
||||||
let mut file_items = Vec::new();
|
let mut file_items = Vec::new();
|
||||||
@ -2669,12 +2747,7 @@ impl Channel for FeishuChannel {
|
|||||||
};
|
};
|
||||||
let file_content = serde_json::json!({"file_key": file_key}).to_string();
|
let file_content = serde_json::json!({"file_key": file_key}).to_string();
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.send_message_to_feishu(
|
.send_raw_message(&target, file_msg_type, &file_content)
|
||||||
receive_id,
|
|
||||||
receive_id_type,
|
|
||||||
file_msg_type,
|
|
||||||
&file_content,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(error = %e, msg_type = file_msg_type, "Failed to send file message");
|
tracing::warn!(error = %e, msg_type = file_msg_type, "Failed to send file message");
|
||||||
@ -2749,51 +2822,7 @@ impl Channel for FeishuChannel {
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = self
|
self.send_raw_message(&target, msg_type, &content).await?;
|
||||||
.http_client
|
|
||||||
.post(format!(
|
|
||||||
"{}/im/v1/messages?receive_id_type={}",
|
|
||||||
FEISHU_API_BASE, receive_id_type
|
|
||||||
))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
|
||||||
.json(&serde_json::json!({
|
|
||||||
"receive_id": receive_id,
|
|
||||||
"msg_type": msg_type,
|
|
||||||
"content": content
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
ChannelError::ConnectionError(format!("Send multimodal message HTTP error: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let send_status = resp.status();
|
|
||||||
let send_body = resp
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChannelError::Other(format!("Failed to read send response: {}", e)))?;
|
|
||||||
tracing::debug!(status = %send_status, body = %send_body, msg_type = %msg_type, "Feishu send message");
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SendResp {
|
|
||||||
code: i32,
|
|
||||||
msg: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let send_resp: SendResp = serde_json::from_str(&send_body).map_err(|e| {
|
|
||||||
ChannelError::Other(format!(
|
|
||||||
"Parse send response error: {} | body: {}",
|
|
||||||
e, &send_body
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if send_resp.code != 0 {
|
|
||||||
return Err(ChannelError::Other(format!(
|
|
||||||
"Send multimodal message failed: code={} msg={}",
|
|
||||||
send_resp.code, send_resp.msg
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove pending reaction after successfully sending
|
// Remove pending reaction after successfully sending
|
||||||
self.remove_reaction_from_metadata(&msg.metadata).await;
|
self.remove_reaction_from_metadata(&msg.metadata).await;
|
||||||
@ -2960,6 +2989,52 @@ mod tests {
|
|||||||
assert_eq!(parsed.content, "ask @Alice");
|
assert_eq!(parsed.content, "ask @Alice");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inbound_context_replies_to_current_message_and_preserves_thread_metadata() {
|
||||||
|
let mut channel = test_channel();
|
||||||
|
channel.config.reaction_emoji.clear();
|
||||||
|
*channel.bot_open_id.write().await = Some("ou_bot".to_string());
|
||||||
|
let mut frame = inbound_frame(
|
||||||
|
"om_current",
|
||||||
|
"ou_user",
|
||||||
|
"group",
|
||||||
|
"@_user_1 hello",
|
||||||
|
serde_json::json!([
|
||||||
|
{"key": "@_user_1", "id": {"open_id": "ou_bot"}, "name": "PicoBot"}
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let mut payload: serde_json::Value =
|
||||||
|
serde_json::from_slice(frame.payload.as_deref().unwrap()).unwrap();
|
||||||
|
payload["event"]["message"]["root_id"] = serde_json::json!("om_root");
|
||||||
|
payload["event"]["message"]["thread_id"] = serde_json::json!("omt_thread");
|
||||||
|
frame.payload = Some(payload.to_string().into_bytes());
|
||||||
|
let bus = Arc::new(MessageBus::new(4));
|
||||||
|
|
||||||
|
channel.process_event_frame(&bus, &frame).await;
|
||||||
|
let inbound = bus.consume_inbound().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
inbound.channel_context.reply_to.as_deref(),
|
||||||
|
Some("om_current")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
inbound
|
||||||
|
.channel_context
|
||||||
|
.private
|
||||||
|
.get("feishu.thread_id")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("omt_thread")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
inbound
|
||||||
|
.channel_context
|
||||||
|
.private
|
||||||
|
.get("feishu.root_id")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("om_root")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_mentions_can_gate_group_messages_when_top_level_mentions_are_absent() {
|
fn post_mentions_can_gate_group_messages_when_top_level_mentions_are_absent() {
|
||||||
let message = LarkMessage {
|
let message = LarkMessage {
|
||||||
@ -2972,6 +3047,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
root_id: None,
|
||||||
|
thread_id: None,
|
||||||
mentions: vec![],
|
mentions: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -3056,6 +3133,32 @@ mod tests {
|
|||||||
assert_eq!(reconnect_delay(100), Duration::from_secs(64));
|
assert_eq!(reconnect_delay(100), Duration::from_secs(64));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_target_uses_reply_endpoint_and_thread_flag() {
|
||||||
|
let metadata = HashMap::from([("feishu.thread_id".to_string(), "omt_thread".to_string())]);
|
||||||
|
let target = FeishuSendTarget::from_message(
|
||||||
|
"oc_chat".to_string(),
|
||||||
|
Some("om_current".to_string()),
|
||||||
|
&metadata,
|
||||||
|
);
|
||||||
|
let (url, body) = target.request("interactive", "{}");
|
||||||
|
|
||||||
|
assert!(url.ends_with("/im/v1/messages/om_current/reply"));
|
||||||
|
assert_eq!(body["reply_in_thread"], true);
|
||||||
|
assert_eq!(body["msg_type"], "interactive");
|
||||||
|
assert!(body.get("receive_id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proactive_thread_target_creates_inside_thread() {
|
||||||
|
let metadata = HashMap::from([("feishu.thread_id".to_string(), "omt_thread".to_string())]);
|
||||||
|
let target = FeishuSendTarget::from_message("oc_chat".to_string(), None, &metadata);
|
||||||
|
let (url, body) = target.request("text", "{}");
|
||||||
|
|
||||||
|
assert!(url.ends_with("/im/v1/messages?receive_id_type=thread_id"));
|
||||||
|
assert_eq!(body["receive_id"], "omt_thread");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn turn_sink_creates_once_updates_same_card_and_cleans_up_at_finish() {
|
async fn turn_sink_creates_once_updates_same_card_and_cleans_up_at_finish() {
|
||||||
let state = Arc::new(Mutex::new(MockTurnState::default()));
|
let state = Arc::new(Mutex::new(MockTurnState::default()));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user