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_BATCH_MESSAGES: usize = 4096;
92const FAILURE_REPLY: &str = "SCV could not complete that request.";
93
94/// Compatibility entry point. Connects to the existing daemon; launches no process.
95pub async fn run(token: &str, base_url: &str, account: &str, workspace: &Path) -> Result<()> {
96    run_supervised(
97        token,
98        base_url,
99        account,
100        workspace,
101        &scv_client::default_socket_path()?,
102        CancellationToken::new(),
103        Arc::new(|_| {}),
104    )
105    .await
106}
107
108/// Run one account until cancelled. Only a validated authenticated getupdates
109/// response reports healthy. Cancellation drops all owned I/O and sessions;
110/// no adapter tasks are spawned. The caller supplies any external stop timeout.
111pub async fn run_supervised(
112    token: &str,
113    base_url: &str,
114    account: &str,
115    workspace: &Path,
116    socket: &Path,
117    cancellation: CancellationToken,
118    report: Arc<dyn Fn(bool) + Send + Sync>,
119) -> Result<()> {
120    until_cancelled(cancellation, async {
121        state::validate_name(account)?;
122        let base_url = normalize_base_url(base_url)?;
123        let store = state::Store::new(state::root()?);
124        let result = run_loop(
125            token,
126            &base_url,
127            account,
128            workspace,
129            socket,
130            &store,
131            report.as_ref(),
132        )
133        .await;
134        if result.is_err() {
135            report(false);
136        }
137        result
138    })
139    .await
140}
141
142async fn until_cancelled(
143    cancellation: CancellationToken,
144    work: impl std::future::Future<Output = Result<()>>,
145) -> Result<()> {
146    tokio::select! {
147        biased;
148        _ = cancellation.cancelled() => Ok(()),
149        result = work => result,
150    }
151}
152
153fn http_client() -> Result<reqwest::Client> {
154    Ok(reqwest::Client::builder()
155        .redirect(reqwest::redirect::Policy::none())
156        .build()?)
157}
158
159async fn response_json(response: reqwest::Response) -> Result<Value> {
160    if !response.status().is_success() {
161        bail!("ClawBot HTTP request failed")
162    }
163    response
164        .json()
165        .await
166        .map_err(|_| anyhow!("invalid ClawBot response"))
167}
168
169async fn run_loop(
170    token: &str,
171    base_url: &str,
172    account: &str,
173    workspace: &Path,
174    socket: &Path,
175    store: &state::Store,
176    report: &(dyn Fn(bool) + Send + Sync),
177) -> Result<()> {
178    let _lock = store.lock(account)?;
179    let client = http_client()?;
180    let mut state = store.bind_state(account, token, base_url)?;
181    let mut sessions: HashMap<String, protocol::Session> = HashMap::new();
182    let mut backoff = Duration::from_secs(1);
183    let delivery = Delivery {
184        client: &client,
185        token,
186        base_url,
187        account,
188        store,
189        report,
190    };
191    recover_interrupted(store, account, &mut state)?;
192    delivery.deliver_pending(&mut state).await?;
193    // Use the durable state's seen list directly, including recovered deliveries.
194    loop {
195        let response = async {
196            let response = client.post(format!("{base_url}/ilink/bot/getupdates"))
197                .headers(bridge::auth_headers(token, u32::from_le_bytes(*Uuid::new_v4().as_bytes().first_chunk::<4>().unwrap())))
198                .json(&serde_json::json!({"get_updates_buf":state.cursor,"base_info":{"channel_version":"1.0.0"}}))
199                .timeout(Duration::from_secs(50)).send().await?;
200            let value = response_json(response).await?;
201            validate_updates(&value)?;
202            Ok::<_, anyhow::Error>(value)
203        }.await;
204        let response = match response {
205            Ok(response) => {
206                report(true);
207                response
208            }
209            Err(_) => {
210                report(false);
211                tokio::time::sleep(backoff).await;
212                backoff = (backoff * 2).min(Duration::from_secs(60));
213                continue;
214            }
215        };
216        backoff = Duration::from_secs(1);
217        sessions.retain(|_, s| s.last_used.elapsed() < Duration::from_secs(1800));
218        for msg in response
219            .get("msgs")
220            .and_then(Value::as_array)
221            .into_iter()
222            .flatten()
223        {
224            let id = msg
225                .get("message_id")
226                .or_else(|| msg.get("msg_id"))
227                .and_then(Value::as_str)
228                .unwrap_or("");
229            if id.is_empty() {
230                continue;
231            }
232            if state.seen.iter().any(|x| x == id) {
233                // Keep all IDs encountered in this bounded batch until its cursor
234                // commits, including IDs recovered from the preceding run.
235                mark_seen(&mut state, id);
236                store.save_state(account, &state)?;
237                continue;
238            }
239            if msg.get("message_type").and_then(Value::as_i64) != Some(1) {
240                mark_seen(&mut state, id);
241                store.save_state(account, &state)?;
242                continue;
243            }
244            let Some(text) = msg
245                .get("item_list")
246                .and_then(Value::as_array)
247                .and_then(|xs| {
248                    xs.iter()
249                        .find_map(|x| x.get("text_item")?.get("text")?.as_str())
250                })
251                .filter(|text| !text.trim().is_empty())
252            else {
253                mark_seen(&mut state, id);
254                store.save_state(account, &state)?;
255                continue;
256            };
257            let Some(sender) = msg
258                .get("from_user_id")
259                .and_then(Value::as_str)
260                .filter(|s| !s.is_empty())
261            else {
262                mark_seen(&mut state, id);
263                store.save_state(account, &state)?;
264                continue;
265            };
266            let Some(ctx) = msg
267                .get("context_token")
268                .and_then(Value::as_str)
269                .filter(|s| !s.is_empty())
270            else {
271                mark_seen(&mut state, id);
272                store.save_state(account, &state)?;
273                continue;
274            };
275            if !sessions.contains_key(sender)
276                && sessions.len() >= 32
277                && let Some(oldest) = sessions
278                    .iter()
279                    .min_by_key(|(_, session)| session.last_used)
280                    .map(|(key, _)| key.clone())
281            {
282                sessions.remove(&oldest);
283            }
284            state.in_flight = Some(state::InFlight {
285                message_id: id.into(),
286                to_user_id: sender.into(),
287                context_token: ctx.into(),
288            });
289            store.save_state(account, &state)?;
290            let result = tokio::time::timeout(Duration::from_secs(300), async {
291                let session = match sessions.entry(sender.into()) {
292                    std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
293                    std::collections::hash_map::Entry::Vacant(e) => {
294                        e.insert(protocol::Session::connect(socket, workspace).await?)
295                    }
296                };
297                session.turn(text, MAX_REPLY_BYTES).await
298            })
299            .await;
300            let reply = match result {
301                Ok(Ok(reply)) => reply,
302                Ok(Err(_)) | Err(_) => {
303                    sessions.remove(sender);
304                    FAILURE_REPLY.into()
305                }
306            };
307            let reply = if reply.trim().is_empty() {
308                "SCV completed without a text response.".into()
309            } else {
310                reply
311            };
312            state.pending = Some(new_pending(id, sender, ctx, &reply, MAX_REPLY_BYTES));
313            state.in_flight = None;
314            store.save_state(account, &state)?;
315            delivery.deliver_pending(&mut state).await?;
316        }
317        if let Some(next) = response.get("get_updates_buf").and_then(Value::as_str) {
318            state.cursor = next.into();
319        }
320        store.save_state(account, &state)?;
321        // Also yield for immediately-ready mocked transports and empty batches.
322        tokio::task::yield_now().await;
323    }
324}
325
326fn validate_updates(value: &Value) -> Result<()> {
327    // Current iLink getupdates responses omit `ret` on success, while error
328    // responses and older servers use the common envelope. Accept both forms.
329    if value.get("ret").is_some() || value.get("errcode").is_some() {
330        check_envelope(value)?;
331    } else if !value.get("msgs").is_some_and(Value::is_array)
332        || !value.get("get_updates_buf").is_some_and(Value::is_string)
333    {
334        bail!("iLink updates response omitted success fields")
335    }
336    if value
337        .get("msgs")
338        .and_then(Value::as_array)
339        .is_some_and(|msgs| msgs.len() > MAX_BATCH_MESSAGES)
340    {
341        bail!("ClawBot updates batch exceeds limit")
342    }
343    if value.get("msgs").is_some_and(|msgs| !msgs.is_array())
344        || value
345            .get("get_updates_buf")
346            .is_some_and(|cursor| !cursor.is_string())
347    {
348        bail!("invalid ClawBot updates response")
349    }
350    Ok(())
351}
352
353fn recover_interrupted(
354    store: &state::Store,
355    account: &str,
356    state: &mut state::BridgeState,
357) -> Result<()> {
358    if let Some(interrupted) = state.in_flight.take() {
359        if state.pending.is_some() {
360            bail!("inconsistent ClawBot delivery state")
361        }
362        state.pending = Some(new_pending(
363            &interrupted.message_id,
364            &interrupted.to_user_id,
365            &interrupted.context_token,
366            FAILURE_REPLY,
367            MAX_REPLY_BYTES,
368        ));
369        store.save_state(account, state)?;
370    }
371    Ok(())
372}
373
374fn new_pending(
375    message_id: &str,
376    to_user_id: &str,
377    context_token: &str,
378    reply: &str,
379    max_bytes: usize,
380) -> state::PendingDelivery {
381    let chunks = split_utf8(reply, max_bytes);
382    state::PendingDelivery {
383        message_id: message_id.to_owned(),
384        to_user_id: to_user_id.to_owned(),
385        context_token: context_token.to_owned(),
386        reply: reply.to_owned(),
387        client_ids: chunks.iter().map(|_| Uuid::new_v4().to_string()).collect(),
388        next_chunk: 0,
389    }
390}
391
392struct Delivery<'a> {
393    client: &'a reqwest::Client,
394    token: &'a str,
395    base_url: &'a str,
396    account: &'a str,
397    store: &'a state::Store,
398    report: &'a (dyn Fn(bool) + Send + Sync),
399}
400
401impl Delivery<'_> {
402    async fn deliver_pending(&self, state: &mut state::BridgeState) -> Result<()> {
403        let Some(mut pending) = state.pending.take() else {
404            return Ok(());
405        };
406        let chunks = split_utf8(&pending.reply, MAX_REPLY_BYTES);
407        while pending.client_ids.len() < chunks.len() {
408            pending.client_ids.push(Uuid::new_v4().to_string());
409        }
410        if pending.next_chunk > chunks.len() {
411            pending.next_chunk = 0;
412        }
413        state.pending = Some(pending.clone());
414        self.store.save_state(self.account, state)?;
415        while pending.next_chunk < chunks.len() {
416            let index = pending.next_chunk;
417            let body = bridge::reply_body(
418                &pending.to_user_id,
419                &pending.context_token,
420                &chunks[index],
421                &pending.client_ids[index],
422            );
423            bridge::send_reply_request(self.client, self.token, self.base_url, &body, self.report)
424                .await?;
425            pending.next_chunk += 1;
426            state.pending = Some(pending.clone());
427            self.store.save_state(self.account, state)?;
428        }
429        if !pending.message_id.is_empty() {
430            mark_seen(state, &pending.message_id);
431        }
432        state.pending = None;
433        self.store.save_state(self.account, state)?;
434        Ok(())
435    }
436}
437
438pub fn normalize_base_url(value: &str) -> Result<String> {
439    let url =
440        reqwest::Url::parse(value.trim()).map_err(|e| anyhow!("invalid ClawBot base URL: {e}"))?;
441    if url.scheme() != "https"
442        || url.host_str().is_none()
443        || !url.username().is_empty()
444        || url.password().is_some()
445        || (url.path() != "/" && !url.path().is_empty())
446        || url.query().is_some()
447        || url.fragment().is_some()
448    {
449        bail!("ClawBot base URL must be an HTTPS origin")
450    }
451    Ok(value.trim().trim_end_matches('/').to_owned())
452}
453
454fn mark_seen(state: &mut state::BridgeState, id: &str) {
455    if let Some(index) = state.seen.iter().position(|seen| seen == id) {
456        state.seen.remove(index);
457    }
458    state.seen.push(id.to_owned());
459    let excess = state.seen.len().saturating_sub(4096);
460    state.seen.drain(..excess);
461}
462
463pub fn check_envelope(value: &serde_json::Value) -> Result<()> {
464    let ret = value
465        .get("ret")
466        .and_then(serde_json::Value::as_i64)
467        .ok_or_else(|| anyhow!("iLink response omitted ret"))?;
468    if ret != 0 || value.get("errcode").is_some_and(|v| v.as_i64() != Some(0)) {
469        bail!("iLink API rejected request")
470    }
471    Ok(())
472}
473
474pub fn split_utf8(value: &str, max: usize) -> Vec<String> {
475    let mut out = Vec::new();
476    let mut rest = value;
477    let max = max.max(1);
478    while rest.len() > max {
479        let mut end = max;
480        while end > 0 && !rest.is_char_boundary(end) {
481            end -= 1;
482        }
483        if end == 0 {
484            end = rest
485                .char_indices()
486                .nth(1)
487                .map_or(rest.len(), |(index, _)| index);
488        }
489        out.push(rest[..end].to_owned());
490        rest = &rest[end..];
491    }
492    if !rest.is_empty() {
493        out.push(rest.to_owned());
494    }
495    if out.is_empty() {
496        out.push(String::new());
497    }
498    out
499}
500
501#[cfg(test)]
502mod lifecycle_tests;
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    #[test]
508    fn validates_origins() {
509        assert!(normalize_base_url("https://example.test").is_ok());
510        assert!(normalize_base_url("http://example.test").is_err());
511        assert!(normalize_base_url("https://user@example.test").is_err());
512    }
513    #[test]
514    fn chunks_on_utf8_boundaries() {
515        let chunks = split_utf8("a🙂b", 4);
516        assert_eq!(chunks, vec!["a", "🙂", "b"]);
517    }
518    #[test]
519    fn chunks_make_progress_below_codepoint_size() {
520        assert_eq!(split_utf8("🙂", 1), vec!["🙂"]);
521        assert_eq!(split_utf8("🙂", 0), vec!["🙂"]);
522    }
523    #[test]
524    fn validates_ret() {
525        assert!(check_envelope(&serde_json::json!({"ret":0})).is_ok());
526        assert!(check_envelope(&serde_json::json!({"ret":1})).is_err());
527    }
528
529    #[test]
530    fn accepts_live_getupdates_success_without_ret() {
531        assert!(
532            validate_updates(&serde_json::json!({
533                "msgs": [],
534                "sync_buf": "sync",
535                "get_updates_buf": "cursor"
536            }))
537            .is_ok()
538        );
539    }
540
541    #[test]
542    fn rejects_getupdates_error_without_ret() {
543        assert!(
544            validate_updates(&serde_json::json!({
545                "errcode": -14,
546                "errmsg": "session timeout"
547            }))
548            .is_err()
549        );
550    }
551}