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/// Errors produced by a [`ReactionModerationBackend`].
67#[derive(Debug, thiserror::Error)]
68pub enum ModerationError {
69    /// The Telegram API returned an error response (`ok: false`).
70    ///
71    /// The description is forwarded from the API and maps to
72    /// [`ToolError::InvalidParams`] so the agent can adjust its call.
73    #[error("Telegram API error: {0}")]
74    Api(String),
75    /// HTTP transport or TLS error.
76    ///
77    /// Maps to a transient [`ToolError::Http`] so the agent may retry.
78    #[error("HTTP error: {0}")]
79    Http(String),
80}
81
82/// Backend that executes reaction-moderation API calls.
83///
84/// Implementors are expected to call the Telegram Bot API. The trait is
85/// object-safe (all methods return pinned boxed futures) so [`ModerationExecutor`]
86/// can hold it as `Arc<dyn ReactionModerationBackend>`.
87///
88/// # Contract
89///
90/// - `delete_reaction` and `delete_all_reactions` must call the Telegram API and
91///   surface both `ok: false` responses as [`ModerationError::Api`] and transport
92///   failures as [`ModerationError::Http`].
93/// - The bot must be an administrator with appropriate rights in the target chat
94///   **before** calling these methods; implementations SHOULD perform a pre-flight
95///   `get_chat_member` check and return [`ModerationError::Api`] when the bot is
96///   not an administrator, rather than forwarding a `Forbidden` error from the API.
97pub trait ReactionModerationBackend: Send + Sync {
98    /// Remove a single reaction left by `user_id` on a message.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`ModerationError`] on API or transport failure.
103    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    /// Remove all reactions left by `user_id` on a message.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`ModerationError`] on API or transport failure.
116    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// ── Executor ───────────────────────────────────────────────────────────────
125
126/// Tool executor for Telegram reaction moderation.
127///
128/// Dispatches the structured tool calls `telegram_delete_reaction` and
129/// `telegram_delete_all_reactions` to the injected [`ReactionModerationBackend`].
130///
131/// Deleting reactions is irreversible — the executor signals
132/// `requires_confirmation = true` so the user can approve before execution.
133///
134/// # Examples
135///
136/// ```no_run
137/// # use zeph_tools::moderation::{ModerationExecutor, ReactionModerationBackend, ModerationError};
138/// # use std::pin::Pin;
139/// #
140/// # struct MockBackend;
141/// # impl ReactionModerationBackend for MockBackend {
142/// #     fn delete_reaction<'a>(&'a self, _: i64, _: i64, _: i64, _: &'a str)
143/// #         -> Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>
144/// #     { Box::pin(async { Ok(()) }) }
145/// #     fn delete_all_reactions<'a>(&'a self, _: i64, _: i64, _: i64)
146/// #         -> Pin<Box<dyn std::future::Future<Output = Result<(), ModerationError>> + Send + 'a>>
147/// #     { Box::pin(async { Ok(()) }) }
148/// # }
149/// #
150/// let executor = ModerationExecutor::new(MockBackend);
151/// ```
152#[derive(Debug)]
153pub struct ModerationExecutor<B> {
154    backend: B,
155}
156
157impl<B: ReactionModerationBackend> ModerationExecutor<B> {
158    /// Create a new executor backed by `backend`.
159    pub fn new(backend: B) -> Self {
160        Self { backend }
161    }
162}
163
164/// Map a [`ModerationError`] to the appropriate [`ToolError`].
165///
166/// `Api` errors — e.g. `"MESSAGE_NOT_FOUND"`, `"REACTION_INVALID"` — map to
167/// [`ToolError::InvalidParams`] because the call parameters were wrong, not a network issue.
168/// `Http` transport errors map to [`ToolError::Http`] with status `502` (Bad Gateway) to signal
169/// a transient upstream failure consistent with how other executors map network errors.
170fn 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    /// Reaction deletion is irreversible — always require confirmation.
294    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// ── Unit tests ─────────────────────────────────────────────────────────────
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::sync::Arc;
308    use std::sync::atomic::{AtomicU32, Ordering};
309
310    // ── Mock backend ───────────────────────────────────────────────────────
311
312    struct MockBackend {
313        delete_calls: Arc<AtomicU32>,
314        delete_all_calls: Arc<AtomicU32>,
315        /// When set to `true`, all calls return `ModerationError::Api`.
316        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    // ── execute returns None for unknown tool ──────────────────────────────
397
398    #[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    // ── delete_reaction success ────────────────────────────────────────────
416
417    #[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    // ── delete_all_reactions success ───────────────────────────────────────
439
440    #[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    // ── API error maps to InvalidParams ───────────────────────────────────
459
460    #[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    // ── Invalid params ─────────────────────────────────────────────────────
500
501    #[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        // reaction field missing
506        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        // user_id field missing
523        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    // ── requires_confirmation ─────────────────────────────────────────────
535
536    #[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    // ── tool_definitions ──────────────────────────────────────────────────
571
572    #[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    // ── Http error maps correctly ─────────────────────────────────────────
584
585    #[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    // ── reaction validation ────────────────────────────────────────────────
593
594    #[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"  // 11 chars — exceeds limit of 10
625            }),
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}