1use schemars::JsonSchema;
30use serde::Deserialize;
31use zeph_common::ToolName;
32
33use crate::executor::{
34 ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
35};
36use crate::registry::{InvocationHint, ToolDef};
37
38#[derive(Debug, Deserialize, JsonSchema)]
42pub struct DeleteReactionParams {
43 pub chat_id: i64,
45 pub message_id: i64,
47 pub user_id: i64,
49 pub reaction: String,
51}
52
53#[derive(Debug, Deserialize, JsonSchema)]
55pub struct DeleteAllReactionsParams {
56 pub chat_id: i64,
58 pub message_id: i64,
60 pub user_id: i64,
62}
63
64#[non_exhaustive]
67#[derive(Debug, thiserror::Error)]
69pub enum ModerationError {
70 #[error("Telegram API error: {0}")]
75 Api(String),
76 #[error("HTTP error: {0}")]
80 Http(String),
81}
82
83pub trait ReactionModerationBackend: Send + Sync {
99 fn delete_reaction<'a>(
105 &'a self,
106 chat_id: i64,
107 message_id: i64,
108 user_id: i64,
109 reaction: &'a str,
110 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>;
111
112 fn delete_all_reactions<'a>(
118 &'a self,
119 chat_id: i64,
120 message_id: i64,
121 user_id: i64,
122 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>;
123}
124
125#[derive(Debug)]
154pub struct ModerationExecutor<B> {
155 backend: B,
156}
157
158impl<B: ReactionModerationBackend> ModerationExecutor<B> {
159 pub fn new(backend: B) -> Self {
161 Self { backend }
162 }
163}
164
165fn moderation_error_to_tool_error(e: ModerationError) -> ToolError {
172 match e {
173 ModerationError::Api(msg) => ToolError::InvalidParams { message: msg },
174 ModerationError::Http(msg) => ToolError::Http {
175 status: 502,
176 message: msg,
177 },
178 }
179}
180
181impl<B: ReactionModerationBackend + std::fmt::Debug> ToolExecutor for ModerationExecutor<B> {
182 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
183 Ok(None)
184 }
185
186 #[tracing::instrument(skip(self), fields(tool_id = %call.tool_id))]
187 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
188 match call.tool_id.as_ref() {
189 "telegram_delete_reaction" => {
190 let p: DeleteReactionParams = deserialize_params(&call.params)?;
191 if p.reaction.is_empty() {
192 return Err(ToolError::InvalidParams {
193 message: "reaction must not be empty".into(),
194 });
195 }
196 if p.reaction.chars().count() > 10 {
197 return Err(ToolError::InvalidParams {
198 message: "reaction string too long".into(),
199 });
200 }
201 tracing::info!(
202 chat_id = p.chat_id,
203 message_id = p.message_id,
204 user_id = p.user_id,
205 reaction = %p.reaction,
206 "moderation: deleting single reaction"
207 );
208 self.backend
209 .delete_reaction(p.chat_id, p.message_id, p.user_id, &p.reaction)
210 .await
211 .map_err(moderation_error_to_tool_error)?;
212 Ok(Some(ToolOutput {
213 tool_name: ToolName::new("telegram_delete_reaction"),
214 summary: format!(
215 "Reaction '{}' removed from message {} in chat {} for user {}.",
216 p.reaction, p.message_id, p.chat_id, p.user_id
217 ),
218 blocks_executed: 1,
219 filter_stats: None,
220 diff: None,
221 streamed: false,
222 terminal_id: None,
223 locations: None,
224 raw_response: None,
225 claim_source: Some(ClaimSource::Moderation),
226 }))
227 }
228 "telegram_delete_all_reactions" => {
229 let p: DeleteAllReactionsParams = deserialize_params(&call.params)?;
230 tracing::info!(
231 chat_id = p.chat_id,
232 message_id = p.message_id,
233 user_id = p.user_id,
234 "moderation: deleting all reactions"
235 );
236 self.backend
237 .delete_all_reactions(p.chat_id, p.message_id, p.user_id)
238 .await
239 .map_err(moderation_error_to_tool_error)?;
240 Ok(Some(ToolOutput {
241 tool_name: ToolName::new("telegram_delete_all_reactions"),
242 summary: format!(
243 "All reactions removed from message {} in chat {} for user {}.",
244 p.message_id, p.chat_id, p.user_id
245 ),
246 blocks_executed: 1,
247 filter_stats: None,
248 diff: None,
249 streamed: false,
250 terminal_id: None,
251 locations: None,
252 raw_response: None,
253 claim_source: Some(ClaimSource::Moderation),
254 }))
255 }
256 _ => Ok(None),
257 }
258 }
259
260 fn tool_definitions(&self) -> Vec<ToolDef> {
261 vec![
262 ToolDef {
263 id: "telegram_delete_reaction".into(),
264 description: "Remove a specific emoji reaction left by a user on a Telegram message.\n\
265 Requires the bot to be an administrator with 'delete_messages' rights in the chat.\n\
266 This action is irreversible.\n\
267 Parameters: chat_id (integer, required) — chat containing the message;\n\
268 message_id (integer, required) — the target message;\n\
269 user_id (integer, required) — the user whose reaction to remove;\n\
270 reaction (string, required) — the emoji to remove (e.g. \"👍\").\n\
271 Returns: confirmation message on success.\n\
272 Errors: InvalidParams when the API returns ok=false; Http on transport failure.".into(),
273 schema: schemars::schema_for!(DeleteReactionParams),
274 invocation: InvocationHint::ToolCall,
275 output_schema: None,
276 server_id: None,
277 },
278 ToolDef {
279 id: "telegram_delete_all_reactions".into(),
280 description: "Remove all emoji reactions left by a user on a Telegram message.\n\
281 Requires the bot to be an administrator with 'delete_messages' rights in the chat.\n\
282 This action is irreversible.\n\
283 Parameters: chat_id (integer, required) — chat containing the message;\n\
284 message_id (integer, required) — the target message;\n\
285 user_id (integer, required) — the user whose reactions to remove.\n\
286 Returns: confirmation message on success.\n\
287 Errors: InvalidParams when the API returns ok=false; Http on transport failure.".into(),
288 schema: schemars::schema_for!(DeleteAllReactionsParams),
289 invocation: InvocationHint::ToolCall,
290 output_schema: None,
291 server_id: None,
292 },
293 ]
294 }
295
296 fn requires_confirmation(&self, call: &ToolCall) -> bool {
298 matches!(
299 call.tool_id.as_ref(),
300 "telegram_delete_reaction" | "telegram_delete_all_reactions"
301 )
302 }
303}
304
305#[cfg(test)]
308mod tests {
309 use super::*;
310 use std::assert_matches;
311 use std::sync::Arc;
312 use std::sync::atomic::{AtomicU32, Ordering};
313
314 struct MockBackend {
317 delete_calls: Arc<AtomicU32>,
318 delete_all_calls: Arc<AtomicU32>,
319 fail: bool,
321 }
322
323 impl MockBackend {
324 fn new(fail: bool) -> (Self, Arc<AtomicU32>, Arc<AtomicU32>) {
325 let d = Arc::new(AtomicU32::new(0));
326 let da = Arc::new(AtomicU32::new(0));
327 (
328 Self {
329 delete_calls: Arc::clone(&d),
330 delete_all_calls: Arc::clone(&da),
331 fail,
332 },
333 d,
334 da,
335 )
336 }
337 }
338
339 impl std::fmt::Debug for MockBackend {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 f.debug_struct("MockBackend").finish_non_exhaustive()
342 }
343 }
344
345 impl ReactionModerationBackend for MockBackend {
346 fn delete_reaction<'a>(
347 &'a self,
348 _chat_id: i64,
349 _message_id: i64,
350 _user_id: i64,
351 _reaction: &'a str,
352 ) -> std::pin::Pin<
353 Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
354 > {
355 let fail = self.fail;
356 let counter = Arc::clone(&self.delete_calls);
357 Box::pin(async move {
358 if fail {
359 Err(ModerationError::Api(
360 "Bad Request: message not found".into(),
361 ))
362 } else {
363 counter.fetch_add(1, Ordering::Relaxed);
364 Ok(())
365 }
366 })
367 }
368
369 fn delete_all_reactions<'a>(
370 &'a self,
371 _chat_id: i64,
372 _message_id: i64,
373 _user_id: i64,
374 ) -> std::pin::Pin<
375 Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
376 > {
377 let fail = self.fail;
378 let counter = Arc::clone(&self.delete_all_calls);
379 Box::pin(async move {
380 if fail {
381 Err(ModerationError::Api("Forbidden: not enough rights".into()))
382 } else {
383 counter.fetch_add(1, Ordering::Relaxed);
384 Ok(())
385 }
386 })
387 }
388 }
389
390 fn make_call(tool_id: &str, params: &serde_json::Value) -> ToolCall {
391 ToolCall {
392 tool_id: ToolName::new(tool_id),
393 params: params.as_object().cloned().unwrap_or_default(),
394 caller_id: None,
395 context: None,
396 tool_call_id: String::new(),
397 skill_name: None,
398 }
399 }
400
401 #[tokio::test]
404 async fn unknown_tool_returns_none() {
405 let (backend, _, _) = MockBackend::new(false);
406 let exec = ModerationExecutor::new(backend);
407 let call = make_call("unknown_tool", &serde_json::json!({}));
408 let result = exec.execute_tool_call(&call).await.unwrap();
409 assert!(result.is_none());
410 }
411
412 #[tokio::test]
413 async fn execute_fenced_returns_none() {
414 let (backend, _, _) = MockBackend::new(false);
415 let exec = ModerationExecutor::new(backend);
416 let result = exec.execute("```bash\necho hi\n```").await.unwrap();
417 assert!(result.is_none());
418 }
419
420 #[tokio::test]
423 async fn delete_reaction_success() {
424 let (backend, d_calls, _) = MockBackend::new(false);
425 let exec = ModerationExecutor::new(backend);
426 let call = make_call(
427 "telegram_delete_reaction",
428 &serde_json::json!({
429 "chat_id": 100,
430 "message_id": 200,
431 "user_id": 300,
432 "reaction": "👍"
433 }),
434 );
435 let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
436 assert_eq!(output.tool_name.as_ref(), "telegram_delete_reaction");
437 assert!(output.summary.contains("👍"));
438 assert!(output.summary.contains("200"));
439 assert_eq!(d_calls.load(Ordering::Relaxed), 1);
440 assert_eq!(output.claim_source, Some(ClaimSource::Moderation));
441 }
442
443 #[tokio::test]
446 async fn delete_all_reactions_success() {
447 let (backend, _, da_calls) = MockBackend::new(false);
448 let exec = ModerationExecutor::new(backend);
449 let call = make_call(
450 "telegram_delete_all_reactions",
451 &serde_json::json!({
452 "chat_id": 100,
453 "message_id": 200,
454 "user_id": 300
455 }),
456 );
457 let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
458 assert_eq!(output.tool_name.as_ref(), "telegram_delete_all_reactions");
459 assert!(output.summary.contains("All reactions removed"));
460 assert_eq!(da_calls.load(Ordering::Relaxed), 1);
461 }
462
463 #[tokio::test]
466 async fn delete_reaction_api_error_maps_to_invalid_params() {
467 let (backend, _, _) = MockBackend::new(true);
468 let exec = ModerationExecutor::new(backend);
469 let call = make_call(
470 "telegram_delete_reaction",
471 &serde_json::json!({
472 "chat_id": 1,
473 "message_id": 2,
474 "user_id": 3,
475 "reaction": "👎"
476 }),
477 );
478 let err = exec.execute_tool_call(&call).await.unwrap_err();
479 assert!(
480 matches!(err, ToolError::InvalidParams { .. }),
481 "expected InvalidParams, got {err:?}"
482 );
483 }
484
485 #[tokio::test]
486 async fn delete_all_reactions_api_error_maps_to_invalid_params() {
487 let (backend, _, _) = MockBackend::new(true);
488 let exec = ModerationExecutor::new(backend);
489 let call = make_call(
490 "telegram_delete_all_reactions",
491 &serde_json::json!({
492 "chat_id": 1,
493 "message_id": 2,
494 "user_id": 3
495 }),
496 );
497 let err = exec.execute_tool_call(&call).await.unwrap_err();
498 assert!(
499 matches!(err, ToolError::InvalidParams { .. }),
500 "expected InvalidParams, got {err:?}"
501 );
502 }
503
504 #[tokio::test]
507 async fn delete_reaction_missing_params_returns_invalid_params() {
508 let (backend, _, _) = MockBackend::new(false);
509 let exec = ModerationExecutor::new(backend);
510 let call = make_call(
512 "telegram_delete_reaction",
513 &serde_json::json!({
514 "chat_id": 1,
515 "message_id": 2,
516 "user_id": 3
517 }),
518 );
519 let err = exec.execute_tool_call(&call).await.unwrap_err();
520 assert_matches!(err, ToolError::InvalidParams { .. });
521 }
522
523 #[tokio::test]
524 async fn delete_all_reactions_missing_params_returns_invalid_params() {
525 let (backend, _, _) = MockBackend::new(false);
526 let exec = ModerationExecutor::new(backend);
527 let call = make_call(
529 "telegram_delete_all_reactions",
530 &serde_json::json!({
531 "chat_id": 1,
532 "message_id": 2
533 }),
534 );
535 let err = exec.execute_tool_call(&call).await.unwrap_err();
536 assert_matches!(err, ToolError::InvalidParams { .. });
537 }
538
539 #[test]
542 fn requires_confirmation_for_delete_reaction() {
543 let (backend, _, _) = MockBackend::new(false);
544 let exec = ModerationExecutor::new(backend);
545 let call = make_call(
546 "telegram_delete_reaction",
547 &serde_json::json!({
548 "chat_id": 1, "message_id": 2, "user_id": 3, "reaction": "👍"
549 }),
550 );
551 assert!(exec.requires_confirmation(&call));
552 }
553
554 #[test]
555 fn requires_confirmation_for_delete_all_reactions() {
556 let (backend, _, _) = MockBackend::new(false);
557 let exec = ModerationExecutor::new(backend);
558 let call = make_call(
559 "telegram_delete_all_reactions",
560 &serde_json::json!({
561 "chat_id": 1, "message_id": 2, "user_id": 3
562 }),
563 );
564 assert!(exec.requires_confirmation(&call));
565 }
566
567 #[test]
568 fn does_not_require_confirmation_for_unknown_tool() {
569 let (backend, _, _) = MockBackend::new(false);
570 let exec = ModerationExecutor::new(backend);
571 let call = make_call("unknown", &serde_json::json!({}));
572 assert!(!exec.requires_confirmation(&call));
573 }
574
575 #[test]
578 fn tool_definitions_returns_two_tools() {
579 let (backend, _, _) = MockBackend::new(false);
580 let exec = ModerationExecutor::new(backend);
581 let defs = exec.tool_definitions();
582 assert_eq!(defs.len(), 2);
583 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
584 assert!(ids.contains(&"telegram_delete_reaction"));
585 assert!(ids.contains(&"telegram_delete_all_reactions"));
586 }
587
588 #[test]
591 fn moderation_error_http_maps_to_tool_error_http_502() {
592 let err = ModerationError::Http("connection refused".into());
593 let te = moderation_error_to_tool_error(err);
594 assert_matches!(te, ToolError::Http { status: 502, .. });
595 }
596
597 #[tokio::test]
600 async fn delete_reaction_empty_reaction_returns_invalid_params() {
601 let (backend, _, _) = MockBackend::new(false);
602 let exec = ModerationExecutor::new(backend);
603 let call = make_call(
604 "telegram_delete_reaction",
605 &serde_json::json!({
606 "chat_id": 1,
607 "message_id": 2,
608 "user_id": 3,
609 "reaction": ""
610 }),
611 );
612 let err = exec.execute_tool_call(&call).await.unwrap_err();
613 assert!(
614 matches!(err, ToolError::InvalidParams { ref message } if message.contains("empty")),
615 "expected empty reaction error, got {err:?}"
616 );
617 }
618
619 #[tokio::test]
620 async fn delete_reaction_overlong_reaction_returns_invalid_params() {
621 let (backend, _, _) = MockBackend::new(false);
622 let exec = ModerationExecutor::new(backend);
623 let call = make_call(
624 "telegram_delete_reaction",
625 &serde_json::json!({
626 "chat_id": 1,
627 "message_id": 2,
628 "user_id": 3,
629 "reaction": "12345678901" }),
631 );
632 let err = exec.execute_tool_call(&call).await.unwrap_err();
633 assert!(
634 matches!(err, ToolError::InvalidParams { ref message } if message.contains("too long")),
635 "expected too long error, got {err:?}"
636 );
637 }
638}