use schemars::JsonSchema;
use serde::Deserialize;
use zeph_common::ToolName;
use crate::executor::{
ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
};
use crate::registry::{InvocationHint, ToolDef};
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeleteReactionParams {
pub chat_id: i64,
pub message_id: i64,
pub user_id: i64,
pub reaction: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeleteAllReactionsParams {
pub chat_id: i64,
pub message_id: i64,
pub user_id: i64,
}
#[derive(Debug, thiserror::Error)]
pub enum ModerationError {
#[error("Telegram API error: {0}")]
Api(String),
#[error("HTTP error: {0}")]
Http(String),
}
pub trait ReactionModerationBackend: Send + Sync {
fn delete_reaction<'a>(
&'a self,
chat_id: i64,
message_id: i64,
user_id: i64,
reaction: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>;
fn delete_all_reactions<'a>(
&'a self,
chat_id: i64,
message_id: i64,
user_id: i64,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>;
}
#[derive(Debug)]
pub struct ModerationExecutor<B> {
backend: B,
}
impl<B: ReactionModerationBackend> ModerationExecutor<B> {
pub fn new(backend: B) -> Self {
Self { backend }
}
}
fn moderation_error_to_tool_error(e: ModerationError) -> ToolError {
match e {
ModerationError::Api(msg) => ToolError::InvalidParams { message: msg },
ModerationError::Http(msg) => ToolError::Http {
status: 502,
message: msg,
},
}
}
impl<B: ReactionModerationBackend + std::fmt::Debug> ToolExecutor for ModerationExecutor<B> {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(None)
}
#[tracing::instrument(skip(self), fields(tool_id = %call.tool_id))]
async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
match call.tool_id.as_ref() {
"telegram_delete_reaction" => {
let p: DeleteReactionParams = deserialize_params(&call.params)?;
if p.reaction.is_empty() {
return Err(ToolError::InvalidParams {
message: "reaction must not be empty".into(),
});
}
if p.reaction.chars().count() > 10 {
return Err(ToolError::InvalidParams {
message: "reaction string too long".into(),
});
}
tracing::info!(
chat_id = p.chat_id,
message_id = p.message_id,
user_id = p.user_id,
reaction = %p.reaction,
"moderation: deleting single reaction"
);
self.backend
.delete_reaction(p.chat_id, p.message_id, p.user_id, &p.reaction)
.await
.map_err(moderation_error_to_tool_error)?;
Ok(Some(ToolOutput {
tool_name: ToolName::new("telegram_delete_reaction"),
summary: format!(
"Reaction '{}' removed from message {} in chat {} for user {}.",
p.reaction, p.message_id, p.chat_id, p.user_id
),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: Some(ClaimSource::Moderation),
}))
}
"telegram_delete_all_reactions" => {
let p: DeleteAllReactionsParams = deserialize_params(&call.params)?;
tracing::info!(
chat_id = p.chat_id,
message_id = p.message_id,
user_id = p.user_id,
"moderation: deleting all reactions"
);
self.backend
.delete_all_reactions(p.chat_id, p.message_id, p.user_id)
.await
.map_err(moderation_error_to_tool_error)?;
Ok(Some(ToolOutput {
tool_name: ToolName::new("telegram_delete_all_reactions"),
summary: format!(
"All reactions removed from message {} in chat {} for user {}.",
p.message_id, p.chat_id, p.user_id
),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: Some(ClaimSource::Moderation),
}))
}
_ => Ok(None),
}
}
fn tool_definitions(&self) -> Vec<ToolDef> {
vec![
ToolDef {
id: "telegram_delete_reaction".into(),
description: "Remove a specific emoji reaction left by a user on a Telegram message.\n\
Requires the bot to be an administrator with 'delete_messages' rights in the chat.\n\
This action is irreversible.\n\
Parameters: chat_id (integer, required) — chat containing the message;\n\
message_id (integer, required) — the target message;\n\
user_id (integer, required) — the user whose reaction to remove;\n\
reaction (string, required) — the emoji to remove (e.g. \"👍\").\n\
Returns: confirmation message on success.\n\
Errors: InvalidParams when the API returns ok=false; Http on transport failure.".into(),
schema: schemars::schema_for!(DeleteReactionParams),
invocation: InvocationHint::ToolCall,
output_schema: None,
},
ToolDef {
id: "telegram_delete_all_reactions".into(),
description: "Remove all emoji reactions left by a user on a Telegram message.\n\
Requires the bot to be an administrator with 'delete_messages' rights in the chat.\n\
This action is irreversible.\n\
Parameters: chat_id (integer, required) — chat containing the message;\n\
message_id (integer, required) — the target message;\n\
user_id (integer, required) — the user whose reactions to remove.\n\
Returns: confirmation message on success.\n\
Errors: InvalidParams when the API returns ok=false; Http on transport failure.".into(),
schema: schemars::schema_for!(DeleteAllReactionsParams),
invocation: InvocationHint::ToolCall,
output_schema: None,
},
]
}
fn requires_confirmation(&self, call: &ToolCall) -> bool {
matches!(
call.tool_id.as_ref(),
"telegram_delete_reaction" | "telegram_delete_all_reactions"
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
struct MockBackend {
delete_calls: Arc<AtomicU32>,
delete_all_calls: Arc<AtomicU32>,
fail: bool,
}
impl MockBackend {
fn new(fail: bool) -> (Self, Arc<AtomicU32>, Arc<AtomicU32>) {
let d = Arc::new(AtomicU32::new(0));
let da = Arc::new(AtomicU32::new(0));
(
Self {
delete_calls: Arc::clone(&d),
delete_all_calls: Arc::clone(&da),
fail,
},
d,
da,
)
}
}
impl std::fmt::Debug for MockBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MockBackend").finish_non_exhaustive()
}
}
impl ReactionModerationBackend for MockBackend {
fn delete_reaction<'a>(
&'a self,
_chat_id: i64,
_message_id: i64,
_user_id: i64,
_reaction: &'a str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
> {
let fail = self.fail;
let counter = Arc::clone(&self.delete_calls);
Box::pin(async move {
if fail {
Err(ModerationError::Api(
"Bad Request: message not found".into(),
))
} else {
counter.fetch_add(1, Ordering::Relaxed);
Ok(())
}
})
}
fn delete_all_reactions<'a>(
&'a self,
_chat_id: i64,
_message_id: i64,
_user_id: i64,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
> {
let fail = self.fail;
let counter = Arc::clone(&self.delete_all_calls);
Box::pin(async move {
if fail {
Err(ModerationError::Api("Forbidden: not enough rights".into()))
} else {
counter.fetch_add(1, Ordering::Relaxed);
Ok(())
}
})
}
}
fn make_call(tool_id: &str, params: &serde_json::Value) -> ToolCall {
ToolCall {
tool_id: ToolName::new(tool_id),
params: params.as_object().cloned().unwrap_or_default(),
caller_id: None,
context: None,
tool_call_id: String::new(),
}
}
#[tokio::test]
async fn unknown_tool_returns_none() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call("unknown_tool", &serde_json::json!({}));
let result = exec.execute_tool_call(&call).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn execute_fenced_returns_none() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let result = exec.execute("```bash\necho hi\n```").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn delete_reaction_success() {
let (backend, d_calls, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_reaction",
&serde_json::json!({
"chat_id": 100,
"message_id": 200,
"user_id": 300,
"reaction": "👍"
}),
);
let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
assert_eq!(output.tool_name.as_ref(), "telegram_delete_reaction");
assert!(output.summary.contains("👍"));
assert!(output.summary.contains("200"));
assert_eq!(d_calls.load(Ordering::Relaxed), 1);
assert_eq!(output.claim_source, Some(ClaimSource::Moderation));
}
#[tokio::test]
async fn delete_all_reactions_success() {
let (backend, _, da_calls) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_all_reactions",
&serde_json::json!({
"chat_id": 100,
"message_id": 200,
"user_id": 300
}),
);
let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
assert_eq!(output.tool_name.as_ref(), "telegram_delete_all_reactions");
assert!(output.summary.contains("All reactions removed"));
assert_eq!(da_calls.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn delete_reaction_api_error_maps_to_invalid_params() {
let (backend, _, _) = MockBackend::new(true);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_reaction",
&serde_json::json!({
"chat_id": 1,
"message_id": 2,
"user_id": 3,
"reaction": "👎"
}),
);
let err = exec.execute_tool_call(&call).await.unwrap_err();
assert!(
matches!(err, ToolError::InvalidParams { .. }),
"expected InvalidParams, got {err:?}"
);
}
#[tokio::test]
async fn delete_all_reactions_api_error_maps_to_invalid_params() {
let (backend, _, _) = MockBackend::new(true);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_all_reactions",
&serde_json::json!({
"chat_id": 1,
"message_id": 2,
"user_id": 3
}),
);
let err = exec.execute_tool_call(&call).await.unwrap_err();
assert!(
matches!(err, ToolError::InvalidParams { .. }),
"expected InvalidParams, got {err:?}"
);
}
#[tokio::test]
async fn delete_reaction_missing_params_returns_invalid_params() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_reaction",
&serde_json::json!({
"chat_id": 1,
"message_id": 2,
"user_id": 3
}),
);
let err = exec.execute_tool_call(&call).await.unwrap_err();
assert!(matches!(err, ToolError::InvalidParams { .. }));
}
#[tokio::test]
async fn delete_all_reactions_missing_params_returns_invalid_params() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_all_reactions",
&serde_json::json!({
"chat_id": 1,
"message_id": 2
}),
);
let err = exec.execute_tool_call(&call).await.unwrap_err();
assert!(matches!(err, ToolError::InvalidParams { .. }));
}
#[test]
fn requires_confirmation_for_delete_reaction() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_reaction",
&serde_json::json!({
"chat_id": 1, "message_id": 2, "user_id": 3, "reaction": "👍"
}),
);
assert!(exec.requires_confirmation(&call));
}
#[test]
fn requires_confirmation_for_delete_all_reactions() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_all_reactions",
&serde_json::json!({
"chat_id": 1, "message_id": 2, "user_id": 3
}),
);
assert!(exec.requires_confirmation(&call));
}
#[test]
fn does_not_require_confirmation_for_unknown_tool() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call("unknown", &serde_json::json!({}));
assert!(!exec.requires_confirmation(&call));
}
#[test]
fn tool_definitions_returns_two_tools() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let defs = exec.tool_definitions();
assert_eq!(defs.len(), 2);
let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
assert!(ids.contains(&"telegram_delete_reaction"));
assert!(ids.contains(&"telegram_delete_all_reactions"));
}
#[test]
fn moderation_error_http_maps_to_tool_error_http_502() {
let err = ModerationError::Http("connection refused".into());
let te = moderation_error_to_tool_error(err);
assert!(matches!(te, ToolError::Http { status: 502, .. }));
}
#[tokio::test]
async fn delete_reaction_empty_reaction_returns_invalid_params() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_reaction",
&serde_json::json!({
"chat_id": 1,
"message_id": 2,
"user_id": 3,
"reaction": ""
}),
);
let err = exec.execute_tool_call(&call).await.unwrap_err();
assert!(
matches!(err, ToolError::InvalidParams { ref message } if message.contains("empty")),
"expected empty reaction error, got {err:?}"
);
}
#[tokio::test]
async fn delete_reaction_overlong_reaction_returns_invalid_params() {
let (backend, _, _) = MockBackend::new(false);
let exec = ModerationExecutor::new(backend);
let call = make_call(
"telegram_delete_reaction",
&serde_json::json!({
"chat_id": 1,
"message_id": 2,
"user_id": 3,
"reaction": "12345678901" }),
);
let err = exec.execute_tool_call(&call).await.unwrap_err();
assert!(
matches!(err, ToolError::InvalidParams { ref message } if message.contains("too long")),
"expected too long error, got {err:?}"
);
}
}