Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/feishu.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,22 @@ To start a threaded conversation: reply to any bot message in a group chat (long

Streaming (typewriter) mode works in threads — edits target the same message regardless of thread context.

## Agent-Controlled Reply-To

Agents can reply to a specific message using the `[[reply_to:message_id]]` output directive (see [docs/output-directives.md](output-directives.md)). The gateway sends the reply via Feishu's native Reply API, showing a quote reference in the UI.

```
Agent output:
[[reply_to:om_xxx]]
This is my reply to that specific message.
```

**How agents get message IDs:** Every incoming message includes `message_id` in the `SenderContext` injected into the agent prompt. Agents can store and reference these IDs to reply to specific messages.

**Fallback:** If the specified message ID is invalid or the Reply API fails, the gateway automatically falls back to a plain send (no quote).

**Use case:** In multi-bot threads, each bot can reply to a different message, creating clear visual conversation threads within a Feishu thread.

## Bot-to-Bot Collaboration (Gateway-Side Only)

The gateway adapter includes bot identification and filtering scaffolding (`AllowBots` enum, `FEISHU_TRUSTED_BOT_IDS`, `FEISHU_MAX_BOT_TURNS` with human-reset safety valve), matching Discord's `allow_bot_messages` design.
Expand Down
3 changes: 2 additions & 1 deletion docs/output-directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Here is my reply to that specific message.

**Behavior**:
- Discord: sends with `message_reference`, showing the native "replying to..." UI
- Feishu: sends via Reply API (`POST /im/v1/messages/{id}/reply`), showing native quote UI
- Invalid/non-existent message ID: silently falls back to plain send
- Works in both streaming and send-once modes

Expand Down Expand Up @@ -73,4 +74,4 @@ This creates clear visual conversation threads within a Discord thread — essen
| Hermes Agent | `DISCORD_REPLY_TO_MODE` env var | ❌ Platform decides, always to trigger msg |
| **OAB** | `[[reply_to:message_id]]` directive | ✅ Agent chooses any message |

> **Note:** `reply_to` is currently implemented for Discord only. Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR.
> **Note:** `reply_to` is currently implemented for Discord and Feishu (gateway). Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR.
101 changes: 99 additions & 2 deletions gateway/src/adapters/feishu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1904,14 +1904,25 @@ pub async fn handle_reply(
let api_base = adapter.config.api_base();
let text = &reply.content.text;
let limit = adapter.config.message_limit;
// quote_message_id (agent-controlled reply-to) takes priority over thread_id
let reply_target = reply.quote_message_id.as_deref()
.or(reply.channel.thread_id.as_deref());
let thread_id = reply.channel.thread_id.as_deref();

// Split long messages; store sent message_ids in dedupe to prevent
// self-echo (Feishu pushes bot's own messages back via WebSocket)
// Use post (rich text) format for markdown rendering.
// When in a thread (thread_id present), use reply API to stay in the same thread.
if text.len() <= limit {
match send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await {
let result = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, text).await;
// Fallback: if quote_message_id caused failure, retry without it
let result = if result.is_none() && reply.quote_message_id.is_some() {
tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "reply-to failed, falling back to plain send");
send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await
} else {
result
};
match result {
Some(msg_id) => {
adapter.dedupe.is_duplicate(&msg_id);
// Record thread participation for mention bypass
Expand Down Expand Up @@ -1953,11 +1964,21 @@ pub async fn handle_reply(
} else {
let mut sent_any = false;
for chunk in split_text(text, limit) {
if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await {
if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, chunk).await {
adapter.dedupe.is_duplicate(&msg_id);
sent_any = true;
}
}
// Fallback: if quote_message_id caused all chunks to fail, retry without it
if !sent_any && reply.quote_message_id.is_some() {
tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "chunked reply-to failed, falling back to plain send");
for chunk in split_text(text, limit) {
if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await {
adapter.dedupe.is_duplicate(&msg_id);
sent_any = true;
}
}
}
if sent_any {
if let Some(tid) = thread_id {
record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs);
Expand Down Expand Up @@ -2942,4 +2963,80 @@ mod tests {
// (caller would pass false because Mentions mode always returns false)
assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}

#[test]
fn quote_message_id_takes_priority_over_thread_id() {
use crate::schema::{GatewayReply, ReplyChannel, Content};
let reply = GatewayReply {
schema: "openab.gateway.reply.v1".into(),
reply_to: "evt_123".into(),
platform: "feishu".into(),
channel: ReplyChannel {
id: "chat_123".into(),
thread_id: Some("om_root".into()),
},
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: vec![],
},
command: None,
request_id: None,
quote_message_id: Some("om_specific".into()),
};
// quote_message_id should take priority
let reply_target = reply.quote_message_id.as_deref()
.or(reply.channel.thread_id.as_deref());
assert_eq!(reply_target, Some("om_specific"));
}

#[test]
fn reply_target_falls_back_to_thread_id_when_no_quote() {
use crate::schema::{GatewayReply, ReplyChannel, Content};
let reply = GatewayReply {
schema: "openab.gateway.reply.v1".into(),
reply_to: "evt_123".into(),
platform: "feishu".into(),
channel: ReplyChannel {
id: "chat_123".into(),
thread_id: Some("om_root".into()),
},
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: vec![],
},
command: None,
request_id: None,
quote_message_id: None,
};
let reply_target = reply.quote_message_id.as_deref()
.or(reply.channel.thread_id.as_deref());
assert_eq!(reply_target, Some("om_root"));
}

#[test]
fn reply_target_is_none_when_both_absent() {
use crate::schema::{GatewayReply, ReplyChannel, Content};
let reply = GatewayReply {
schema: "openab.gateway.reply.v1".into(),
reply_to: "evt_123".into(),
platform: "feishu".into(),
channel: ReplyChannel {
id: "chat_123".into(),
thread_id: None,
},
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: vec![],
},
command: None,
request_id: None,
quote_message_id: None,
};
let reply_target = reply.quote_message_id.as_deref()
.or(reply.channel.thread_id.as_deref());
assert_eq!(reply_target, None);
}
}
8 changes: 8 additions & 0 deletions gateway/src/adapters/googlechat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,7 @@ mod tests {
},
command: None,
request_id: Some("req_123".into()),
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down Expand Up @@ -1418,6 +1419,7 @@ mod tests {
},
command: None,
request_id: Some("req_fail".into()),
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down Expand Up @@ -1465,6 +1467,7 @@ mod tests {
},
command: None,
request_id: Some("req_empty".into()),
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down Expand Up @@ -1509,6 +1512,7 @@ mod tests {
},
command: None,
request_id: Some("req_multi_fail".into()),
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down Expand Up @@ -1543,6 +1547,7 @@ mod tests {
},
command: None,
request_id: Some("req_notoken".into()),
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down Expand Up @@ -1588,6 +1593,7 @@ mod tests {
},
command: Some("edit_message".into()),
request_id: None,
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down Expand Up @@ -1630,6 +1636,7 @@ mod tests {
},
command: None,
request_id: Some("req_multi".into()),
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down Expand Up @@ -1687,6 +1694,7 @@ mod tests {
},
command: None,
request_id: Some("req_partial".into()),
quote_message_id: None,
};

adapter.handle_reply(&reply, &event_tx).await;
Expand Down
1 change: 1 addition & 0 deletions gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ mod tests {
},
command: None,
request_id: None,
quote_message_id: None,
}
}

Expand Down
6 changes: 6 additions & 0 deletions gateway/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ pub struct GatewayReply {
pub command: Option<String>,
#[serde(default)]
pub request_id: Option<String>,
/// When set, send this message as a reply/quote to the specified platform message ID.
/// Unlike `reply_to` (which identifies the triggering event for routing/dedup),
/// this field controls the visual reply/quote UI on the platform.
/// If quoting fails, the gateway MUST fall back to sending without quoting.
#[serde(default)]
pub quote_message_id: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
Expand Down
Loading
Loading