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 },
277 ToolDef {
278 id: "telegram_delete_all_reactions".into(),
279 description: "Remove all emoji reactions left by a user on a Telegram message.\n\
280 Requires the bot to be an administrator with 'delete_messages' rights in the chat.\n\
281 This action is irreversible.\n\
282 Parameters: chat_id (integer, required) — chat containing the message;\n\
283 message_id (integer, required) — the target message;\n\
284 user_id (integer, required) — the user whose reactions to remove.\n\
285 Returns: confirmation message on success.\n\
286 Errors: InvalidParams when the API returns ok=false; Http on transport failure.".into(),
287 schema: schemars::schema_for!(DeleteAllReactionsParams),
288 invocation: InvocationHint::ToolCall,
289 output_schema: None,
290 },
291 ]
292 }
293
294 fn requires_confirmation(&self, call: &ToolCall) -> bool {
296 matches!(
297 call.tool_id.as_ref(),
298 "telegram_delete_reaction" | "telegram_delete_all_reactions"
299 )
300 }
301}
302
303#[cfg(test)]
306mod tests {
307 use super::*;
308 use std::sync::Arc;
309 use std::sync::atomic::{AtomicU32, Ordering};
310
311 struct MockBackend {
314 delete_calls: Arc<AtomicU32>,
315 delete_all_calls: Arc<AtomicU32>,
316 fail: bool,
318 }
319
320 impl MockBackend {
321 fn new(fail: bool) -> (Self, Arc<AtomicU32>, Arc<AtomicU32>) {
322 let d = Arc::new(AtomicU32::new(0));
323 let da = Arc::new(AtomicU32::new(0));
324 (
325 Self {
326 delete_calls: Arc::clone(&d),
327 delete_all_calls: Arc::clone(&da),
328 fail,
329 },
330 d,
331 da,
332 )
333 }
334 }
335
336 impl std::fmt::Debug for MockBackend {
337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338 f.debug_struct("MockBackend").finish_non_exhaustive()
339 }
340 }
341
342 impl ReactionModerationBackend for MockBackend {
343 fn delete_reaction<'a>(
344 &'a self,
345 _chat_id: i64,
346 _message_id: i64,
347 _user_id: i64,
348 _reaction: &'a str,
349 ) -> std::pin::Pin<
350 Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
351 > {
352 let fail = self.fail;
353 let counter = Arc::clone(&self.delete_calls);
354 Box::pin(async move {
355 if fail {
356 Err(ModerationError::Api(
357 "Bad Request: message not found".into(),
358 ))
359 } else {
360 counter.fetch_add(1, Ordering::Relaxed);
361 Ok(())
362 }
363 })
364 }
365
366 fn delete_all_reactions<'a>(
367 &'a self,
368 _chat_id: i64,
369 _message_id: i64,
370 _user_id: i64,
371 ) -> std::pin::Pin<
372 Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>,
373 > {
374 let fail = self.fail;
375 let counter = Arc::clone(&self.delete_all_calls);
376 Box::pin(async move {
377 if fail {
378 Err(ModerationError::Api("Forbidden: not enough rights".into()))
379 } else {
380 counter.fetch_add(1, Ordering::Relaxed);
381 Ok(())
382 }
383 })
384 }
385 }
386
387 fn make_call(tool_id: &str, params: &serde_json::Value) -> ToolCall {
388 ToolCall {
389 tool_id: ToolName::new(tool_id),
390 params: params.as_object().cloned().unwrap_or_default(),
391 caller_id: None,
392 context: None,
393 tool_call_id: String::new(),
394 skill_name: None,
395 }
396 }
397
398 #[tokio::test]
401 async fn unknown_tool_returns_none() {
402 let (backend, _, _) = MockBackend::new(false);
403 let exec = ModerationExecutor::new(backend);
404 let call = make_call("unknown_tool", &serde_json::json!({}));
405 let result = exec.execute_tool_call(&call).await.unwrap();
406 assert!(result.is_none());
407 }
408
409 #[tokio::test]
410 async fn execute_fenced_returns_none() {
411 let (backend, _, _) = MockBackend::new(false);
412 let exec = ModerationExecutor::new(backend);
413 let result = exec.execute("```bash\necho hi\n```").await.unwrap();
414 assert!(result.is_none());
415 }
416
417 #[tokio::test]
420 async fn delete_reaction_success() {
421 let (backend, d_calls, _) = MockBackend::new(false);
422 let exec = ModerationExecutor::new(backend);
423 let call = make_call(
424 "telegram_delete_reaction",
425 &serde_json::json!({
426 "chat_id": 100,
427 "message_id": 200,
428 "user_id": 300,
429 "reaction": "👍"
430 }),
431 );
432 let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
433 assert_eq!(output.tool_name.as_ref(), "telegram_delete_reaction");
434 assert!(output.summary.contains("👍"));
435 assert!(output.summary.contains("200"));
436 assert_eq!(d_calls.load(Ordering::Relaxed), 1);
437 assert_eq!(output.claim_source, Some(ClaimSource::Moderation));
438 }
439
440 #[tokio::test]
443 async fn delete_all_reactions_success() {
444 let (backend, _, da_calls) = MockBackend::new(false);
445 let exec = ModerationExecutor::new(backend);
446 let call = make_call(
447 "telegram_delete_all_reactions",
448 &serde_json::json!({
449 "chat_id": 100,
450 "message_id": 200,
451 "user_id": 300
452 }),
453 );
454 let output = exec.execute_tool_call(&call).await.unwrap().unwrap();
455 assert_eq!(output.tool_name.as_ref(), "telegram_delete_all_reactions");
456 assert!(output.summary.contains("All reactions removed"));
457 assert_eq!(da_calls.load(Ordering::Relaxed), 1);
458 }
459
460 #[tokio::test]
463 async fn delete_reaction_api_error_maps_to_invalid_params() {
464 let (backend, _, _) = MockBackend::new(true);
465 let exec = ModerationExecutor::new(backend);
466 let call = make_call(
467 "telegram_delete_reaction",
468 &serde_json::json!({
469 "chat_id": 1,
470 "message_id": 2,
471 "user_id": 3,
472 "reaction": "👎"
473 }),
474 );
475 let err = exec.execute_tool_call(&call).await.unwrap_err();
476 assert!(
477 matches!(err, ToolError::InvalidParams { .. }),
478 "expected InvalidParams, got {err:?}"
479 );
480 }
481
482 #[tokio::test]
483 async fn delete_all_reactions_api_error_maps_to_invalid_params() {
484 let (backend, _, _) = MockBackend::new(true);
485 let exec = ModerationExecutor::new(backend);
486 let call = make_call(
487 "telegram_delete_all_reactions",
488 &serde_json::json!({
489 "chat_id": 1,
490 "message_id": 2,
491 "user_id": 3
492 }),
493 );
494 let err = exec.execute_tool_call(&call).await.unwrap_err();
495 assert!(
496 matches!(err, ToolError::InvalidParams { .. }),
497 "expected InvalidParams, got {err:?}"
498 );
499 }
500
501 #[tokio::test]
504 async fn delete_reaction_missing_params_returns_invalid_params() {
505 let (backend, _, _) = MockBackend::new(false);
506 let exec = ModerationExecutor::new(backend);
507 let call = make_call(
509 "telegram_delete_reaction",
510 &serde_json::json!({
511 "chat_id": 1,
512 "message_id": 2,
513 "user_id": 3
514 }),
515 );
516 let err = exec.execute_tool_call(&call).await.unwrap_err();
517 assert!(matches!(err, ToolError::InvalidParams { .. }));
518 }
519
520 #[tokio::test]
521 async fn delete_all_reactions_missing_params_returns_invalid_params() {
522 let (backend, _, _) = MockBackend::new(false);
523 let exec = ModerationExecutor::new(backend);
524 let call = make_call(
526 "telegram_delete_all_reactions",
527 &serde_json::json!({
528 "chat_id": 1,
529 "message_id": 2
530 }),
531 );
532 let err = exec.execute_tool_call(&call).await.unwrap_err();
533 assert!(matches!(err, ToolError::InvalidParams { .. }));
534 }
535
536 #[test]
539 fn requires_confirmation_for_delete_reaction() {
540 let (backend, _, _) = MockBackend::new(false);
541 let exec = ModerationExecutor::new(backend);
542 let call = make_call(
543 "telegram_delete_reaction",
544 &serde_json::json!({
545 "chat_id": 1, "message_id": 2, "user_id": 3, "reaction": "👍"
546 }),
547 );
548 assert!(exec.requires_confirmation(&call));
549 }
550
551 #[test]
552 fn requires_confirmation_for_delete_all_reactions() {
553 let (backend, _, _) = MockBackend::new(false);
554 let exec = ModerationExecutor::new(backend);
555 let call = make_call(
556 "telegram_delete_all_reactions",
557 &serde_json::json!({
558 "chat_id": 1, "message_id": 2, "user_id": 3
559 }),
560 );
561 assert!(exec.requires_confirmation(&call));
562 }
563
564 #[test]
565 fn does_not_require_confirmation_for_unknown_tool() {
566 let (backend, _, _) = MockBackend::new(false);
567 let exec = ModerationExecutor::new(backend);
568 let call = make_call("unknown", &serde_json::json!({}));
569 assert!(!exec.requires_confirmation(&call));
570 }
571
572 #[test]
575 fn tool_definitions_returns_two_tools() {
576 let (backend, _, _) = MockBackend::new(false);
577 let exec = ModerationExecutor::new(backend);
578 let defs = exec.tool_definitions();
579 assert_eq!(defs.len(), 2);
580 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
581 assert!(ids.contains(&"telegram_delete_reaction"));
582 assert!(ids.contains(&"telegram_delete_all_reactions"));
583 }
584
585 #[test]
588 fn moderation_error_http_maps_to_tool_error_http_502() {
589 let err = ModerationError::Http("connection refused".into());
590 let te = moderation_error_to_tool_error(err);
591 assert!(matches!(te, ToolError::Http { status: 502, .. }));
592 }
593
594 #[tokio::test]
597 async fn delete_reaction_empty_reaction_returns_invalid_params() {
598 let (backend, _, _) = MockBackend::new(false);
599 let exec = ModerationExecutor::new(backend);
600 let call = make_call(
601 "telegram_delete_reaction",
602 &serde_json::json!({
603 "chat_id": 1,
604 "message_id": 2,
605 "user_id": 3,
606 "reaction": ""
607 }),
608 );
609 let err = exec.execute_tool_call(&call).await.unwrap_err();
610 assert!(
611 matches!(err, ToolError::InvalidParams { ref message } if message.contains("empty")),
612 "expected empty reaction error, got {err:?}"
613 );
614 }
615
616 #[tokio::test]
617 async fn delete_reaction_overlong_reaction_returns_invalid_params() {
618 let (backend, _, _) = MockBackend::new(false);
619 let exec = ModerationExecutor::new(backend);
620 let call = make_call(
621 "telegram_delete_reaction",
622 &serde_json::json!({
623 "chat_id": 1,
624 "message_id": 2,
625 "user_id": 3,
626 "reaction": "12345678901" }),
628 );
629 let err = exec.execute_tool_call(&call).await.unwrap_err();
630 assert!(
631 matches!(err, ToolError::InvalidParams { ref message } if message.contains("too long")),
632 "expected too long error, got {err:?}"
633 );
634 }
635}