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 ..Default::default()
227 }))
228 }
229 "telegram_delete_all_reactions" => {
230 let p: DeleteAllReactionsParams = deserialize_params(&call.params)?;
231 tracing::info!(
232 chat_id = p.chat_id,
233 message_id = p.message_id,
234 user_id = p.user_id,
235 "moderation: deleting all reactions"
236 );
237 self.backend
238 .delete_all_reactions(p.chat_id, p.message_id, p.user_id)
239 .await
240 .map_err(moderation_error_to_tool_error)?;
241 Ok(Some(ToolOutput {
242 tool_name: ToolName::new("telegram_delete_all_reactions"),
243 summary: format!(
244 "All reactions removed from message {} in chat {} for user {}.",
245 p.message_id, p.chat_id, p.user_id
246 ),
247 blocks_executed: 1,
248 filter_stats: None,
249 diff: None,
250 streamed: false,
251 terminal_id: None,
252 locations: None,
253 raw_response: None,
254 claim_source: Some(ClaimSource::Moderation),
255 ..Default::default()
256 }))
257 }
258 _ => Ok(None),
259 }
260 }
261
262 fn tool_definitions(&self) -> Vec<ToolDef> {
263 vec![
264 ToolDef {
265 id: "telegram_delete_reaction".into(),
266 description: "Remove a specific emoji reaction left by a user on a Telegram message.\n\
267 Requires the bot to be an administrator with 'delete_messages' rights in the chat.\n\
268 This action is irreversible.\n\
269 Parameters: chat_id (integer, required) — chat containing the message;\n\
270 message_id (integer, required) — the target message;\n\
271 user_id (integer, required) — the user whose reaction to remove;\n\
272 reaction (string, required) — the emoji to remove (e.g. \"👍\").\n\
273 Returns: confirmation message on success.\n\
274 Errors: InvalidParams when the API returns ok=false; Http on transport failure.".into(),
275 schema: schemars::schema_for!(DeleteReactionParams),
276 invocation: InvocationHint::ToolCall,
277 output_schema: None,
278 server_id: None,
279 },
280 ToolDef {
281 id: "telegram_delete_all_reactions".into(),
282 description: "Remove all emoji reactions left by a user on a Telegram message.\n\
283 Requires the bot to be an administrator with 'delete_messages' rights in the chat.\n\
284 This action is irreversible.\n\
285 Parameters: chat_id (integer, required) — chat containing the message;\n\
286 message_id (integer, required) — the target message;\n\
287 user_id (integer, required) — the user whose reactions to remove.\n\
288 Returns: confirmation message on success.\n\
289 Errors: InvalidParams when the API returns ok=false; Http on transport failure.".into(),
290 schema: schemars::schema_for!(DeleteAllReactionsParams),
291 invocation: InvocationHint::ToolCall,
292 output_schema: None,
293 server_id: None,
294 },
295 ]
296 }
297
298 fn requires_confirmation(&self, call: &ToolCall) -> bool {
300 matches!(
301 call.tool_id.as_ref(),
302 "telegram_delete_reaction" | "telegram_delete_all_reactions"
303 )
304 }
305
306 async fn execute_tool_call_confirmed(
307 &self,
308 call: &ToolCall,
309 ) -> Result<Option<ToolOutput>, ToolError> {
310 self.execute_tool_call(call).await
311 }
312
313 fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
314 crate::executor::CheckpointActionResult::unsupported()
315 }
316
317 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
318 crate::executor::CheckpointActionResult::unsupported()
319 }
320
321 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
322 crate::executor::CheckpointListResult::default()
323 }
324
325 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
327 false
328 }
329}
330
331#[cfg(test)]
334mod tests {
335 use super::*;
336 use std::assert_matches;
337 use std::sync::Arc;
338 use std::sync::atomic::{AtomicU32, Ordering};
339
340 struct MockBackend {
343 delete_calls: Arc<AtomicU32>,
344 delete_all_calls: Arc<AtomicU32>,
345 fail: bool,
347 }
348
349 impl MockBackend {
350 fn new(fail: bool) -> (Self, Arc<AtomicU32>, Arc<AtomicU32>) {
351 let d = Arc::new(AtomicU32::new(0));
352 let da = Arc::new(AtomicU32::new(0));
353 (
354 Self {
355 delete_calls: Arc::clone(&d),
356 delete_all_calls: Arc::clone(&da),
357 fail,
358 },
359 d,
360 da,
361 )
362 }
363 }
364
365 impl std::fmt::Debug for MockBackend {
366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 f.debug_struct("MockBackend").finish_non_exhaustive()
368 }
369 }
370
371 impl ReactionModerationBackend for MockBackend {
372 fn delete_reaction<'a>(
373 &'a self,
374 _chat_id: i64,
375 _message_id: i64,
376 _user_id: i64,
377 _reaction: &'a str,
378 ) -> std::pin::Pin<
379 Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
380 > {
381 let fail = self.fail;
382 let counter = Arc::clone(&self.delete_calls);
383 Box::pin(async move {
384 if fail {
385 Err(ModerationError::Api(
386 "Bad Request: message not found".into(),
387 ))
388 } else {
389 counter.fetch_add(1, Ordering::Relaxed);
390 Ok(())
391 }
392 })
393 }
394
395 fn delete_all_reactions<'a>(
396 &'a self,
397 _chat_id: i64,
398 _message_id: i64,
399 _user_id: i64,
400 ) -> std::pin::Pin<
401 Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
402 > {
403 let fail = self.fail;
404 let counter = Arc::clone(&self.delete_all_calls);
405 Box::pin(async move {
406 if fail {
407 Err(ModerationError::Api("Forbidden: not enough rights".into()))
408 } else {
409 counter.fetch_add(1, Ordering::Relaxed);
410 Ok(())
411 }
412 })
413 }
414 }
415
416 fn make_call(tool_id: &str, params: &serde_json::Value) -> ToolCall {
417 ToolCall {
418 tool_id: ToolName::new(tool_id),
419 params: params.as_object().cloned().unwrap_or_default(),
420 caller_id: None,
421 context: None,
422 tool_call_id: String::new(),
423 skill_name: None,
424 }
425 }
426
427 #[tokio::test]
430 async fn unknown_tool_returns_none() {
431 let (backend, _, _) = MockBackend::new(false);
432 let exec = ModerationExecutor::new(backend);
433 let call = make_call("unknown_tool", &serde_json::json!({}));
434 let result = exec.execute_tool_call(&call).await.unwrap();
435 assert!(result.is_none());
436 }
437
438 #[tokio::test]
439 async fn execute_fenced_returns_none() {
440 let (backend, _, _) = MockBackend::new(false);
441 let exec = ModerationExecutor::new(backend);
442 let result = exec.execute("```bash\necho hi\n```").await.unwrap();
443 assert!(result.is_none());
444 }
445
446 #[tokio::test]
449 async fn delete_reaction_success() {
450 let (backend, d_calls, _) = MockBackend::new(false);
451 let exec = ModerationExecutor::new(backend);
452 let call = make_call(
453 "telegram_delete_reaction",
454 &serde_json::json!({
455 "chat_id": 100,
456 "message_id": 200,
457 "user_id": 300,
458 "reaction": "👍"
459 }),
460 );
461 let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
462 assert_eq!(output.tool_name.as_ref(), "telegram_delete_reaction");
463 assert!(output.summary.contains("👍"));
464 assert!(output.summary.contains("200"));
465 assert_eq!(d_calls.load(Ordering::Relaxed), 1);
466 assert_eq!(output.claim_source, Some(ClaimSource::Moderation));
467 }
468
469 #[tokio::test]
472 async fn delete_all_reactions_success() {
473 let (backend, _, da_calls) = MockBackend::new(false);
474 let exec = ModerationExecutor::new(backend);
475 let call = make_call(
476 "telegram_delete_all_reactions",
477 &serde_json::json!({
478 "chat_id": 100,
479 "message_id": 200,
480 "user_id": 300
481 }),
482 );
483 let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
484 assert_eq!(output.tool_name.as_ref(), "telegram_delete_all_reactions");
485 assert!(output.summary.contains("All reactions removed"));
486 assert_eq!(da_calls.load(Ordering::Relaxed), 1);
487 }
488
489 #[tokio::test]
492 async fn delete_reaction_api_error_maps_to_invalid_params() {
493 let (backend, _, _) = MockBackend::new(true);
494 let exec = ModerationExecutor::new(backend);
495 let call = make_call(
496 "telegram_delete_reaction",
497 &serde_json::json!({
498 "chat_id": 1,
499 "message_id": 2,
500 "user_id": 3,
501 "reaction": "👎"
502 }),
503 );
504 let err = exec.execute_tool_call(&call).await.unwrap_err();
505 assert!(
506 matches!(err, ToolError::InvalidParams { .. }),
507 "expected InvalidParams, got {err:?}"
508 );
509 }
510
511 #[tokio::test]
512 async fn delete_all_reactions_api_error_maps_to_invalid_params() {
513 let (backend, _, _) = MockBackend::new(true);
514 let exec = ModerationExecutor::new(backend);
515 let call = make_call(
516 "telegram_delete_all_reactions",
517 &serde_json::json!({
518 "chat_id": 1,
519 "message_id": 2,
520 "user_id": 3
521 }),
522 );
523 let err = exec.execute_tool_call(&call).await.unwrap_err();
524 assert!(
525 matches!(err, ToolError::InvalidParams { .. }),
526 "expected InvalidParams, got {err:?}"
527 );
528 }
529
530 #[tokio::test]
533 async fn delete_reaction_missing_params_returns_invalid_params() {
534 let (backend, _, _) = MockBackend::new(false);
535 let exec = ModerationExecutor::new(backend);
536 let call = make_call(
538 "telegram_delete_reaction",
539 &serde_json::json!({
540 "chat_id": 1,
541 "message_id": 2,
542 "user_id": 3
543 }),
544 );
545 let err = exec.execute_tool_call(&call).await.unwrap_err();
546 assert_matches!(err, ToolError::InvalidParams { .. });
547 }
548
549 #[tokio::test]
550 async fn delete_all_reactions_missing_params_returns_invalid_params() {
551 let (backend, _, _) = MockBackend::new(false);
552 let exec = ModerationExecutor::new(backend);
553 let call = make_call(
555 "telegram_delete_all_reactions",
556 &serde_json::json!({
557 "chat_id": 1,
558 "message_id": 2
559 }),
560 );
561 let err = exec.execute_tool_call(&call).await.unwrap_err();
562 assert_matches!(err, ToolError::InvalidParams { .. });
563 }
564
565 #[test]
568 fn requires_confirmation_for_delete_reaction() {
569 let (backend, _, _) = MockBackend::new(false);
570 let exec = ModerationExecutor::new(backend);
571 let call = make_call(
572 "telegram_delete_reaction",
573 &serde_json::json!({
574 "chat_id": 1, "message_id": 2, "user_id": 3, "reaction": "👍"
575 }),
576 );
577 assert!(exec.requires_confirmation(&call));
578 }
579
580 #[test]
581 fn requires_confirmation_for_delete_all_reactions() {
582 let (backend, _, _) = MockBackend::new(false);
583 let exec = ModerationExecutor::new(backend);
584 let call = make_call(
585 "telegram_delete_all_reactions",
586 &serde_json::json!({
587 "chat_id": 1, "message_id": 2, "user_id": 3
588 }),
589 );
590 assert!(exec.requires_confirmation(&call));
591 }
592
593 #[test]
594 fn does_not_require_confirmation_for_unknown_tool() {
595 let (backend, _, _) = MockBackend::new(false);
596 let exec = ModerationExecutor::new(backend);
597 let call = make_call("unknown", &serde_json::json!({}));
598 assert!(!exec.requires_confirmation(&call));
599 }
600
601 #[test]
604 fn tool_definitions_returns_two_tools() {
605 let (backend, _, _) = MockBackend::new(false);
606 let exec = ModerationExecutor::new(backend);
607 let defs = exec.tool_definitions();
608 assert_eq!(defs.len(), 2);
609 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
610 assert!(ids.contains(&"telegram_delete_reaction"));
611 assert!(ids.contains(&"telegram_delete_all_reactions"));
612 }
613
614 #[test]
617 fn moderation_error_http_maps_to_tool_error_http_502() {
618 let err = ModerationError::Http("connection refused".into());
619 let te = moderation_error_to_tool_error(err);
620 assert_matches!(te, ToolError::Http { status: 502, .. });
621 }
622
623 #[tokio::test]
626 async fn delete_reaction_empty_reaction_returns_invalid_params() {
627 let (backend, _, _) = MockBackend::new(false);
628 let exec = ModerationExecutor::new(backend);
629 let call = make_call(
630 "telegram_delete_reaction",
631 &serde_json::json!({
632 "chat_id": 1,
633 "message_id": 2,
634 "user_id": 3,
635 "reaction": ""
636 }),
637 );
638 let err = exec.execute_tool_call(&call).await.unwrap_err();
639 assert!(
640 matches!(err, ToolError::InvalidParams { ref message } if message.contains("empty")),
641 "expected empty reaction error, got {err:?}"
642 );
643 }
644
645 #[tokio::test]
646 async fn delete_reaction_overlong_reaction_returns_invalid_params() {
647 let (backend, _, _) = MockBackend::new(false);
648 let exec = ModerationExecutor::new(backend);
649 let call = make_call(
650 "telegram_delete_reaction",
651 &serde_json::json!({
652 "chat_id": 1,
653 "message_id": 2,
654 "user_id": 3,
655 "reaction": "12345678901" }),
657 );
658 let err = exec.execute_tool_call(&call).await.unwrap_err();
659 assert!(
660 matches!(err, ToolError::InvalidParams { ref message } if message.contains("too long")),
661 "expected too long error, got {err:?}"
662 );
663 }
664}