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