1use 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);
96const OWNER_TURN_TIMEOUT: Duration = Duration::from_secs(1800);
98const OWNER_TURN_MARGIN: Duration = Duration::from_secs(300);
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct ToolOwner {
104 pub user_id: String,
106 pub turn_timeout: Duration,
108}
109
110pub 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
119pub 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#[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 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 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 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 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 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 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
561pub 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}