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_BATCH_MESSAGES: usize = 4096;
92const FAILURE_REPLY: &str = "SCV could not complete that request.";
93
94pub 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
108pub 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 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 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 tokio::task::yield_now().await;
323 }
324}
325
326fn validate_updates(value: &Value) -> Result<()> {
327 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}