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