Skip to main content

scv_clawbot/
lib.rs

1//! Safe, testable primitives for the WeChat iLink ClawBot adapter.
2
3use anyhow::{Result, anyhow, bail};
4
5pub mod bridge;
6pub mod protocol;
7pub mod state;
8
9use serde_json::Value;
10use std::{
11    collections::HashMap,
12    path::Path,
13    sync::Arc,
14    time::{Duration, Instant},
15};
16use tokio_util::sync::CancellationToken;
17use uuid::Uuid;
18
19pub async fn login(base: &str, account: &str) -> Result<()> {
20    state::validate_name(account)?;
21    let client = http_client()?;
22    let base = normalize_base_url(base)?;
23    let qr = response_json(
24        client
25            .get(format!("{base}/ilink/bot/get_bot_qrcode?bot_type=3"))
26            .timeout(Duration::from_secs(20))
27            .send()
28            .await?,
29    )
30    .await?;
31    check_envelope(&qr)?;
32    let code = qr
33        .get("qrcode")
34        .and_then(Value::as_str)
35        .ok_or_else(|| anyhow!("login response omitted qrcode"))?;
36    println!(
37        "Scan this ClawBot QR code in WeChat:\n{}",
38        qr.get("qrcode_img_content")
39            .and_then(Value::as_str)
40            .unwrap_or(code)
41    );
42    let deadline = Instant::now() + Duration::from_secs(300);
43    loop {
44        if Instant::now() >= deadline {
45            bail!("ClawBot QR login timed out; run `scv clawbot login` again")
46        }
47        let status = response_json(
48            client
49                .get(format!("{base}/ilink/bot/get_qrcode_status"))
50                .query(&[("qrcode", code)])
51                .timeout(Duration::from_secs(50))
52                .send()
53                .await?,
54        )
55        .await?;
56        check_envelope(&status)?;
57        match status
58            .get("status")
59            .and_then(Value::as_str)
60            .unwrap_or("unknown")
61        {
62            "confirmed" => {
63                let (token, bot_id, user_id) = bridge::validate_confirmed_login(&status)?;
64                let host = normalize_base_url(
65                    status
66                        .get("baseurl")
67                        .and_then(Value::as_str)
68                        .unwrap_or(&base),
69                )?;
70                bridge::validate_origin_pair(&base, &host)?;
71                state::save_account(
72                    account,
73                    &state::Account {
74                        token: token.into(),
75                        base_url: host.clone(),
76                        bot_id: Some(bot_id.into()),
77                        user_id: Some(user_id.into()),
78                    },
79                )?;
80                println!("ClawBot login confirmed for {bot_id} at {host}.");
81                return Ok(());
82            }
83            "expired" => bail!("ClawBot QR code expired; run `scv clawbot login` again"),
84            _ => {}
85        }
86        tokio::time::sleep(Duration::from_secs(2)).await;
87    }
88}
89
90const MAX_REPLY_BYTES: usize = 16 * 1024;
91const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
92const MAX_MESSAGE_ID_BYTES: usize = 256;
93const MAX_BATCH_MESSAGES: usize = 4096;
94const FAILURE_REPLY: &str = "SCV could not complete that request.";
95const TURN_TIMEOUT: Duration = Duration::from_secs(300);
96/// Owner turns may run tools and delegated agents, which take longer.
97const OWNER_TURN_TIMEOUT: Duration = Duration::from_secs(1800);
98/// Model time an owner turn keeps beyond its longest single tool call.
99const OWNER_TURN_MARGIN: Duration = Duration::from_secs(300);
100
101/// The account owner granted remote tools.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct ToolOwner {
104    /// The owner's authenticated iLink user ID.
105    pub user_id: String,
106    /// How long one owner turn may run; see [`owner_turn_timeout`].
107    pub turn_timeout: Duration,
108}
109
110/// An owner turn outlasts the longest tool call the session allows
111/// (`tools.max_timeout_seconds`) by a margin for the model's own work, and
112/// never runs shorter than 30 minutes.
113pub fn owner_turn_timeout(max_tool_timeout: Duration) -> Duration {
114    max_tool_timeout
115        .saturating_add(OWNER_TURN_MARGIN)
116        .max(OWNER_TURN_TIMEOUT)
117}
118
119/// Compatibility entry point. Connects to the existing daemon; launches no process.
120pub async fn run(token: &str, base_url: &str, account: &str, workspace: &Path) -> Result<()> {
121    run_supervised(
122        token,
123        base_url,
124        account,
125        workspace,
126        &scv_client::default_socket_path()?,
127        None,
128        CancellationToken::new(),
129        Arc::new(|_| {}),
130    )
131    .await
132}
133
134/// Run one account until cancelled. Only a validated authenticated getupdates
135/// response reports healthy. Cancellation drops all owned I/O and sessions;
136/// no adapter tasks are spawned. The caller supplies any external stop timeout.
137///
138/// `tool_owner` is the authenticated owner when the account grants its owner
139/// remote tools; every other sender stays tool-free.
140#[allow(clippy::too_many_arguments)]
141pub async fn run_supervised(
142    token: &str,
143    base_url: &str,
144    account: &str,
145    workspace: &Path,
146    socket: &Path,
147    tool_owner: Option<&ToolOwner>,
148    cancellation: CancellationToken,
149    report: Arc<dyn Fn(bool) + Send + Sync>,
150) -> Result<()> {
151    until_cancelled(cancellation, async {
152        state::validate_name(account)?;
153        let base_url = normalize_base_url(base_url)?;
154        let store = state::Store::new(state::root()?);
155        let result = run_loop(
156            token,
157            &base_url,
158            account,
159            workspace,
160            socket,
161            tool_owner,
162            &store,
163            report.as_ref(),
164        )
165        .await;
166        if result.is_err() {
167            report(false);
168        }
169        result
170    })
171    .await
172}
173
174async fn until_cancelled(
175    cancellation: CancellationToken,
176    work: impl std::future::Future<Output = Result<()>>,
177) -> Result<()> {
178    tokio::select! {
179        biased;
180        _ = cancellation.cancelled() => Ok(()),
181        result = work => result,
182    }
183}
184
185fn http_client() -> Result<reqwest::Client> {
186    Ok(reqwest::Client::builder()
187        .redirect(reqwest::redirect::Policy::none())
188        .build()?)
189}
190
191async fn response_json(response: reqwest::Response) -> Result<Value> {
192    let body = response_body(response).await?;
193    serde_json::from_slice(&body).map_err(|_| anyhow!("invalid ClawBot response"))
194}
195
196async fn response_body(mut response: reqwest::Response) -> Result<Vec<u8>> {
197    if !response.status().is_success() {
198        bail!(
199            "ClawBot HTTP request failed with status {}",
200            response.status()
201        )
202    }
203    if response
204        .content_length()
205        .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
206    {
207        bail!("ClawBot response exceeds limit")
208    }
209    let mut body = Vec::new();
210    while let Some(chunk) = response.chunk().await? {
211        if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
212            bail!("ClawBot response exceeds limit")
213        }
214        body.extend_from_slice(&chunk);
215    }
216    Ok(body)
217}
218
219#[allow(clippy::too_many_arguments)]
220async fn run_loop(
221    token: &str,
222    base_url: &str,
223    account: &str,
224    workspace: &Path,
225    socket: &Path,
226    tool_owner: Option<&ToolOwner>,
227    store: &state::Store,
228    report: &(dyn Fn(bool) + Send + Sync),
229) -> Result<()> {
230    let _lock = store.lock(account)?;
231    let client = http_client()?;
232    let mut state = store.bind_state(account, token, base_url)?;
233    let mut sessions: HashMap<String, protocol::Session> = HashMap::new();
234    let mut backoff = Duration::from_secs(1);
235    let delivery = Delivery {
236        client: &client,
237        token,
238        base_url,
239        account,
240        store,
241        report,
242    };
243    recover_interrupted(store, account, &mut state)?;
244    delivery.deliver_pending(&mut state).await?;
245    // Use the durable state's seen list directly, including recovered deliveries.
246    loop {
247        let response = async {
248            let response = client.post(format!("{base_url}/ilink/bot/getupdates"))
249                .headers(bridge::auth_headers(token, u32::from_le_bytes(*Uuid::new_v4().as_bytes().first_chunk::<4>().unwrap())))
250                .json(&serde_json::json!({"get_updates_buf":state.cursor,"base_info":{"channel_version":"1.0.0"}}))
251                .timeout(Duration::from_secs(50)).send().await?;
252            let value = response_json(response).await?;
253            validate_updates(&value)?;
254            Ok::<_, anyhow::Error>(value)
255        }.await;
256        let response = match response {
257            Ok(response) => {
258                report(true);
259                response
260            }
261            Err(error) => {
262                tracing::warn!("ClawBot poll failed: {error}");
263                report(false);
264                tokio::time::sleep(backoff).await;
265                backoff = (backoff * 2).min(Duration::from_secs(60));
266                continue;
267            }
268        };
269        backoff = Duration::from_secs(1);
270        sessions.retain(|_, s| s.last_used.elapsed() < Duration::from_secs(1800));
271        for msg in response
272            .get("msgs")
273            .and_then(Value::as_array)
274            .into_iter()
275            .flatten()
276        {
277            let Some(id) = message_id(msg) else {
278                continue;
279            };
280            if state.seen.iter().any(|seen| seen == &id) {
281                // Keep all IDs encountered in this bounded batch until its cursor
282                // commits, including IDs recovered from the preceding run.
283                mark_seen(&mut state, &id);
284                store.save_state(account, &state)?;
285                continue;
286            }
287            if msg.get("message_type").and_then(Value::as_i64) != Some(1) {
288                mark_seen(&mut state, &id);
289                store.save_state(account, &state)?;
290                continue;
291            }
292            let Some(text) = msg
293                .get("item_list")
294                .and_then(Value::as_array)
295                .and_then(|xs| {
296                    xs.iter()
297                        .find_map(|x| x.get("text_item")?.get("text")?.as_str())
298                })
299                .filter(|text| !text.trim().is_empty())
300            else {
301                mark_seen(&mut state, &id);
302                store.save_state(account, &state)?;
303                continue;
304            };
305            let Some(sender) = msg
306                .get("from_user_id")
307                .and_then(Value::as_str)
308                .filter(|s| !s.is_empty())
309            else {
310                mark_seen(&mut state, &id);
311                store.save_state(account, &state)?;
312                continue;
313            };
314            let Some(ctx) = msg
315                .get("context_token")
316                .and_then(Value::as_str)
317                .filter(|s| !s.is_empty())
318            else {
319                mark_seen(&mut state, &id);
320                store.save_state(account, &state)?;
321                continue;
322            };
323            // Group messages never carry owner authority and never share the
324            // sender's direct-chat session, whose history may hold tool output.
325            let group = match msg.get("group_id") {
326                None | Some(Value::Null) => None,
327                Some(Value::String(group)) if group.is_empty() => None,
328                Some(Value::String(group)) => Some(group.clone()),
329                Some(other) => Some(other.to_string()),
330            };
331            let key = group
332                .as_ref()
333                .map_or_else(|| sender.to_owned(), |group| format!("{group}\0{sender}"));
334            if !sessions.contains_key(&key)
335                && sessions.len() >= 32
336                && let Some(oldest) = sessions
337                    .iter()
338                    .min_by_key(|(_, session)| session.last_used)
339                    .map(|(key, _)| key.clone())
340            {
341                sessions.remove(&oldest);
342            }
343            state.in_flight = Some(state::InFlight {
344                message_id: id.clone(),
345                to_user_id: sender.into(),
346                context_token: ctx.into(),
347            });
348            store.save_state(account, &state)?;
349            let owner = group.is_none()
350                && tool_owner.is_some_and(|tool_owner| tool_owner.user_id == sender);
351            let limit = match tool_owner {
352                Some(tool_owner) if owner => tool_owner.turn_timeout,
353                _ => TURN_TIMEOUT,
354            };
355            let result = tokio::time::timeout(limit, async {
356                let session = match sessions.entry(key.clone()) {
357                    std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
358                    std::collections::hash_map::Entry::Vacant(e) => {
359                        if owner {
360                            tracing::info!("ClawBot owner session starts with remote tools");
361                        }
362                        e.insert(protocol::Session::connect(socket, workspace, owner).await?)
363                    }
364                };
365                session.turn(text, MAX_REPLY_BYTES).await
366            })
367            .await;
368            let reply = match result {
369                Ok(Ok(reply)) => reply,
370                Ok(Err(_)) | Err(_) => {
371                    sessions.remove(&key);
372                    FAILURE_REPLY.into()
373                }
374            };
375            let reply = if reply.trim().is_empty() {
376                "SCV completed without a text response.".into()
377            } else {
378                reply
379            };
380            state.pending = Some(new_pending(&id, sender, ctx, &reply, MAX_REPLY_BYTES));
381            state.in_flight = None;
382            store.save_state(account, &state)?;
383            delivery.deliver_pending(&mut state).await?;
384        }
385        if let Some(next) = response.get("get_updates_buf").and_then(Value::as_str) {
386            state.cursor = next.into();
387        }
388        store.save_state(account, &state)?;
389        // Also yield for immediately-ready mocked transports and empty batches.
390        tokio::task::yield_now().await;
391    }
392}
393
394fn message_id(msg: &Value) -> Option<String> {
395    let value = msg.get("message_id").or_else(|| msg.get("msg_id"))?;
396    match value {
397        Value::String(id) if !id.is_empty() && id.len() <= MAX_MESSAGE_ID_BYTES => Some(id.clone()),
398        Value::Number(id) if id.as_u64().is_some() => Some(id.to_string()),
399        _ => None,
400    }
401}
402
403fn validate_updates(value: &Value) -> Result<()> {
404    // Current iLink getupdates responses omit `ret` on success, while error
405    // responses and older servers use the common envelope. Accept both forms.
406    if value.get("ret").is_some() || value.get("errcode").is_some() {
407        check_envelope(value)?;
408    } else if !value.get("msgs").is_some_and(Value::is_array)
409        || !value.get("get_updates_buf").is_some_and(Value::is_string)
410    {
411        bail!("iLink updates response omitted success fields")
412    }
413    if value
414        .get("msgs")
415        .and_then(Value::as_array)
416        .is_some_and(|msgs| msgs.len() > MAX_BATCH_MESSAGES)
417    {
418        bail!("ClawBot updates batch exceeds limit")
419    }
420    if value.get("msgs").is_some_and(|msgs| !msgs.is_array())
421        || value
422            .get("get_updates_buf")
423            .is_some_and(|cursor| !cursor.is_string())
424    {
425        bail!("invalid ClawBot updates response")
426    }
427    Ok(())
428}
429
430fn recover_interrupted(
431    store: &state::Store,
432    account: &str,
433    state: &mut state::BridgeState,
434) -> Result<()> {
435    if let Some(interrupted) = state.in_flight.take() {
436        if state.pending.is_some() {
437            bail!("inconsistent ClawBot delivery state")
438        }
439        state.pending = Some(new_pending(
440            &interrupted.message_id,
441            &interrupted.to_user_id,
442            &interrupted.context_token,
443            FAILURE_REPLY,
444            MAX_REPLY_BYTES,
445        ));
446        store.save_state(account, state)?;
447    }
448    Ok(())
449}
450
451fn new_pending(
452    message_id: &str,
453    to_user_id: &str,
454    context_token: &str,
455    reply: &str,
456    max_bytes: usize,
457) -> state::PendingDelivery {
458    let chunks = split_utf8(reply, max_bytes);
459    state::PendingDelivery {
460        message_id: message_id.to_owned(),
461        to_user_id: to_user_id.to_owned(),
462        context_token: context_token.to_owned(),
463        reply: reply.to_owned(),
464        client_ids: chunks.iter().map(|_| Uuid::new_v4().to_string()).collect(),
465        next_chunk: 0,
466    }
467}
468
469struct Delivery<'a> {
470    client: &'a reqwest::Client,
471    token: &'a str,
472    base_url: &'a str,
473    account: &'a str,
474    store: &'a state::Store,
475    report: &'a (dyn Fn(bool) + Send + Sync),
476}
477
478impl Delivery<'_> {
479    async fn deliver_pending(&self, state: &mut state::BridgeState) -> Result<()> {
480        let Some(mut pending) = state.pending.take() else {
481            return Ok(());
482        };
483        let chunks = split_utf8(&pending.reply, MAX_REPLY_BYTES);
484        while pending.client_ids.len() < chunks.len() {
485            pending.client_ids.push(Uuid::new_v4().to_string());
486        }
487        if pending.next_chunk > chunks.len() {
488            pending.next_chunk = 0;
489        }
490        state.pending = Some(pending.clone());
491        self.store.save_state(self.account, state)?;
492        while pending.next_chunk < chunks.len() {
493            let index = pending.next_chunk;
494            let body = bridge::reply_body(
495                &pending.to_user_id,
496                &pending.context_token,
497                &chunks[index],
498                &pending.client_ids[index],
499            );
500            let outcome = bridge::send_reply_request(
501                self.client,
502                self.token,
503                self.base_url,
504                &body,
505                self.report,
506            )
507            .await?;
508            if outcome == bridge::SendOutcome::Rejected {
509                // Explicit refusal is final; drop the rest of this reply.
510                break;
511            }
512            pending.next_chunk += 1;
513            state.pending = Some(pending.clone());
514            self.store.save_state(self.account, state)?;
515        }
516        if !pending.message_id.is_empty() {
517            mark_seen(state, &pending.message_id);
518        }
519        state.pending = None;
520        self.store.save_state(self.account, state)?;
521        Ok(())
522    }
523}
524
525pub fn normalize_base_url(value: &str) -> Result<String> {
526    let url =
527        reqwest::Url::parse(value.trim()).map_err(|e| anyhow!("invalid ClawBot base URL: {e}"))?;
528    if url.scheme() != "https"
529        || url.host_str().is_none()
530        || !url.username().is_empty()
531        || url.password().is_some()
532        || (url.path() != "/" && !url.path().is_empty())
533        || url.query().is_some()
534        || url.fragment().is_some()
535    {
536        bail!("ClawBot base URL must be an HTTPS origin")
537    }
538    Ok(value.trim().trim_end_matches('/').to_owned())
539}
540
541fn mark_seen(state: &mut state::BridgeState, id: &str) {
542    if let Some(index) = state.seen.iter().position(|seen| seen == id) {
543        state.seen.remove(index);
544    }
545    state.seen.push(id.to_owned());
546    let excess = state.seen.len().saturating_sub(4096);
547    state.seen.drain(..excess);
548}
549
550pub fn check_envelope(value: &serde_json::Value) -> Result<()> {
551    let ret = value
552        .get("ret")
553        .and_then(serde_json::Value::as_i64)
554        .ok_or_else(|| anyhow!("iLink response omitted ret"))?;
555    if ret != 0 || value.get("errcode").is_some_and(|v| v.as_i64() != Some(0)) {
556        bail!("iLink API rejected request")
557    }
558    Ok(())
559}
560
561/// Classify a 2xx iLink sendmessage body. Live acknowledgements omit `ret`
562/// and need not be JSON, so only an explicit non-zero `ret` or `errcode`
563/// rejects; the error is a bounded diagnostic without message content or
564/// non-integer code values.
565pub fn check_send_ack(body: &[u8]) -> std::result::Result<(), String> {
566    let Ok(Value::Object(value)) = serde_json::from_slice::<Value>(body) else {
567        return Ok(());
568    };
569    let code = |key: &str| {
570        value
571            .get(key)
572            .filter(|v| !v.is_null() && v.as_i64() != Some(0))
573            .map(|v| {
574                v.as_i64()
575                    .map_or_else(|| "non-integer".into(), |n| n.to_string())
576            })
577    };
578    let (ret, errcode) = (code("ret"), code("errcode"));
579    if ret.is_none() && errcode.is_none() {
580        return Ok(());
581    }
582    let errmsg: String = value
583        .get("errmsg")
584        .and_then(Value::as_str)
585        .unwrap_or_default()
586        .chars()
587        .take(120)
588        .collect();
589    Err(format!(
590        "ret={} errcode={} errmsg={errmsg:?}",
591        ret.as_deref().unwrap_or("-"),
592        errcode.as_deref().unwrap_or("-")
593    ))
594}
595
596pub fn split_utf8(value: &str, max: usize) -> Vec<String> {
597    let mut out = Vec::new();
598    let mut rest = value;
599    let max = max.max(1);
600    while rest.len() > max {
601        let mut end = max;
602        while end > 0 && !rest.is_char_boundary(end) {
603            end -= 1;
604        }
605        if end == 0 {
606            end = rest
607                .char_indices()
608                .nth(1)
609                .map_or(rest.len(), |(index, _)| index);
610        }
611        out.push(rest[..end].to_owned());
612        rest = &rest[end..];
613    }
614    if !rest.is_empty() {
615        out.push(rest.to_owned());
616    }
617    if out.is_empty() {
618        out.push(String::new());
619    }
620    out
621}
622
623#[cfg(test)]
624mod lifecycle_tests;
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    #[test]
630    fn validates_origins() {
631        assert!(normalize_base_url("https://example.test").is_ok());
632        assert!(normalize_base_url("http://example.test").is_err());
633        assert!(normalize_base_url("https://user@example.test").is_err());
634    }
635    #[test]
636    fn chunks_on_utf8_boundaries() {
637        let chunks = split_utf8("a🙂b", 4);
638        assert_eq!(chunks, vec!["a", "🙂", "b"]);
639    }
640    #[test]
641    fn chunks_make_progress_below_codepoint_size() {
642        assert_eq!(split_utf8("🙂", 1), vec!["🙂"]);
643        assert_eq!(split_utf8("🙂", 0), vec!["🙂"]);
644    }
645    #[test]
646    fn validates_ret() {
647        assert!(check_envelope(&serde_json::json!({"ret":0})).is_ok());
648        assert!(check_envelope(&serde_json::json!({"ret":1})).is_err());
649    }
650
651    #[test]
652    fn accepts_live_send_ack_without_ret() {
653        for delivered in [
654            &b""[..],
655            b"{}",
656            br#"{"ret":0}"#,
657            br#"{"ret":null}"#,
658            b"ok",
659            b"[]",
660        ] {
661            assert!(check_send_ack(delivered).is_ok());
662        }
663        assert_eq!(
664            check_send_ack(br#"{"ret":-2,"errmsg":"prepare failed"}"#).unwrap_err(),
665            r#"ret=-2 errcode=- errmsg="prepare failed""#
666        );
667        assert!(check_send_ack(br#"{"errcode":40001}"#).is_err());
668        assert!(check_send_ack(br#"{"ret":"0"}"#).is_err());
669        assert_eq!(
670            check_send_ack(br#"{"ret":{"detail":"x"},"errcode":7}"#).unwrap_err(),
671            r#"ret=non-integer errcode=7 errmsg="""#
672        );
673    }
674
675    #[test]
676    fn accepts_live_getupdates_success_without_ret() {
677        assert!(
678            validate_updates(&serde_json::json!({
679                "msgs": [],
680                "sync_buf": "sync",
681                "get_updates_buf": "cursor"
682            }))
683            .is_ok()
684        );
685    }
686
687    #[test]
688    fn rejects_getupdates_error_without_ret() {
689        assert!(
690            validate_updates(&serde_json::json!({
691                "errcode": -14,
692                "errmsg": "session timeout"
693            }))
694            .is_err()
695        );
696    }
697
698    #[test]
699    fn preserves_string_and_unsigned_numeric_message_ids() {
700        assert_eq!(
701            message_id(&serde_json::json!({"message_id": "string-id"})).as_deref(),
702            Some("string-id")
703        );
704        assert_eq!(
705            message_id(&serde_json::json!({"message_id": u64::MAX})).as_deref(),
706            Some("18446744073709551615")
707        );
708        assert_eq!(
709            message_id(&serde_json::json!({"msg_id": 42})).as_deref(),
710            Some("42")
711        );
712        assert!(message_id(&serde_json::json!({"message_id": null, "msg_id": 42})).is_none());
713        assert!(message_id(&serde_json::json!({"message_id": -1})).is_none());
714        assert!(message_id(&serde_json::json!({"message_id": 1.5})).is_none());
715        assert!(message_id(&serde_json::from_str(r#"{"message_id":1e3}"#).unwrap()).is_none());
716        assert!(
717            message_id(&serde_json::from_str(r#"{"message_id":18446744073709551616}"#).unwrap())
718                .is_none()
719        );
720        assert!(
721            message_id(&serde_json::json!({
722                "message_id": "x".repeat(MAX_MESSAGE_ID_BYTES + 1)
723            }))
724            .is_none()
725        );
726    }
727}