Skip to main content

zeph_tools/
moderation.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Reaction moderation executor for Telegram Bot API 10.0.
5//!
6//! Exposes two structured tool calls — `telegram_delete_reaction` and
7//! `telegram_delete_all_reactions` — that let the agent remove emoji reactions
8//! from messages in chats where the bot has admin rights.
9//!
10//! The executor is platform-agnostic: it delegates the actual API calls to
11//! a [`ReactionModerationBackend`] implementation, keeping `zeph-tools`
12//! independent of `zeph-channels`.
13//!
14//! # Wiring
15//!
16//! In `src/agent_setup.rs`, build a `TelegramModerationBackend` (from
17//! `zeph-channels`) and wrap it with [`ModerationExecutor`]:
18//!
19//! ```ignore
20//! use zeph_channels::telegram_moderation::TelegramModerationBackend;
21//! use zeph_tools::moderation::ModerationExecutor;
22//!
23//! let api = telegram_channel.api_ext().clone();
24//! let me = api.get_me().await?;
25//! let backend = TelegramModerationBackend::new(api, me.id);
26//! let executor = ModerationExecutor::new(backend);
27//! ```
28
29use 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// ── Tool parameter schemas ─────────────────────────────────────────────────
39
40/// Parameters for `telegram_delete_reaction`.
41#[derive(Debug, Deserialize, JsonSchema)]
42pub struct DeleteReactionParams {
43    /// Telegram chat identifier (numeric).
44    pub chat_id: i64,
45    /// Identifier of the message whose reaction should be removed.
46    pub message_id: i64,
47    /// Telegram user identifier whose reaction to remove.
48    pub user_id: i64,
49    /// Emoji or custom reaction string to remove (e.g. `"👍"`).
50    pub reaction: String,
51}
52
53/// Parameters for `telegram_delete_all_reactions`.
54#[derive(Debug, Deserialize, JsonSchema)]
55pub struct DeleteAllReactionsParams {
56    /// Telegram chat identifier (numeric).
57    pub chat_id: i64,
58    /// Identifier of the message whose reactions should be cleared.
59    pub message_id: i64,
60    /// Telegram user identifier whose reactions to remove.
61    pub user_id: i64,
62}
63
64// ── Backend trait ──────────────────────────────────────────────────────────
65
66#[non_exhaustive]
67/// Errors produced by a [`ReactionModerationBackend`].
68#[derive(Debug, thiserror::Error)]
69pub enum ModerationError {
70    /// The Telegram API returned an error response (`ok: false`).
71    ///
72    /// The description is forwarded from the API and maps to
73    /// [`ToolError::InvalidParams`] so the agent can adjust its call.
74    #[error("Telegram API error: {0}")]
75    Api(String),
76    /// HTTP transport or TLS error.
77    ///
78    /// Maps to a transient [`ToolError::Http`] so the agent may retry.
79    #[error("HTTP error: {0}")]
80    Http(String),
81}
82
83/// Backend that executes reaction-moderation API calls.
84///
85/// Implementors are expected to call the Telegram Bot API. The trait is
86/// object-safe (all methods return pinned boxed futures) so [`ModerationExecutor`]
87/// can hold it as `Arc<dyn ReactionModerationBackend>`.
88///
89/// # Contract
90///
91/// - `delete_reaction` and `delete_all_reactions` must call the Telegram API and
92///   surface both `ok: false` responses as [`ModerationError::Api`] and transport
93///   failures as [`ModerationError::Http`].
94/// - The bot must be an administrator with appropriate rights in the target chat
95///   **before** calling these methods; implementations SHOULD perform a pre-flight
96///   `get_chat_member` check and return [`ModerationError::Api`] when the bot is
97///   not an administrator, rather than forwarding a `Forbidden` error from the API.
98pub trait ReactionModerationBackend: Send + Sync {
99    /// Remove a single reaction left by `user_id` on a message.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`ModerationError`] on API or transport failure.
104    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    /// Remove all reactions left by `user_id` on a message.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`ModerationError`] on API or transport failure.
117    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// ── Executor ───────────────────────────────────────────────────────────────
126
127/// Tool executor for Telegram reaction moderation.
128///
129/// Dispatches the structured tool calls `telegram_delete_reaction` and
130/// `telegram_delete_all_reactions` to the injected [`ReactionModerationBackend`].
131///
132/// Deleting reactions is irreversible — the executor signals
133/// `requires_confirmation = true` so the user can approve before execution.
134///
135/// # Examples
136///
137/// ```no_run
138/// # use zeph_tools::moderation::{ModerationExecutor, ReactionModerationBackend, ModerationError};
139/// # use std::pin::Pin;
140/// #
141/// # struct MockBackend;
142/// # impl ReactionModerationBackend for MockBackend {
143/// #     fn delete_reaction<'a>(&'a self, _: i64, _: i64, _: i64, _: &'a str)
144/// #         -> Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>
145/// #     { Box::pin(async { Ok(()) }) }
146/// #     fn delete_all_reactions<'a>(&'a self, _: i64, _: i64, _: i64)
147/// #         -> Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>
148/// #     { Box::pin(async { Ok(()) }) }
149/// # }
150/// #
151/// let executor = ModerationExecutor::new(MockBackend);
152/// ```
153#[derive(Debug)]
154pub struct ModerationExecutor<B> {
155    backend: B,
156}
157
158impl<B: ReactionModerationBackend> ModerationExecutor<B> {
159    /// Create a new executor backed by `backend`.
160    pub fn new(backend: B) -> Self {
161        Self { backend }
162    }
163}
164
165/// Map a [`ModerationError`] to the appropriate [`ToolError`].
166///
167/// `Api` errors — e.g. `"MESSAGE_NOT_FOUND"`, `"REACTION_INVALID"` — map to
168/// [`ToolError::InvalidParams`] because the call parameters were wrong, not a network issue.
169/// `Http` transport errors map to [`ToolError::Http`] with status `502` (Bad Gateway) to signal
170/// a transient upstream failure consistent with how other executors map network errors.
171fn 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    /// Reaction deletion is irreversible — always require confirmation.
295    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// ── Unit tests ─────────────────────────────────────────────────────────────
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::sync::Arc;
309    use std::sync::atomic::{AtomicU32, Ordering};
310
311    // ── Mock backend ───────────────────────────────────────────────────────
312
313    struct MockBackend {
314        delete_calls: Arc<AtomicU32>,
315        delete_all_calls: Arc<AtomicU32>,
316        /// When set to `true`, all calls return `ModerationError::Api`.
317        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    // ── execute returns None for unknown tool ──────────────────────────────
399
400    #[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    // ── delete_reaction success ────────────────────────────────────────────
418
419    #[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    // ── delete_all_reactions success ───────────────────────────────────────
441
442    #[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    // ── API error maps to InvalidParams ───────────────────────────────────
461
462    #[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    // ── Invalid params ─────────────────────────────────────────────────────
502
503    #[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        // reaction field missing
508        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        // user_id field missing
525        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    // ── requires_confirmation ─────────────────────────────────────────────
537
538    #[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    // ── tool_definitions ──────────────────────────────────────────────────
573
574    #[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    // ── Http error maps correctly ─────────────────────────────────────────
586
587    #[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    // ── reaction validation ────────────────────────────────────────────────
595
596    #[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"  // 11 chars — exceeds limit of 10
627            }),
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}