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