1use anyhow::Context;
2use reqwest::Method;
3use reqwest::header::{HeaderMap, HeaderValue};
4use serde_json::json;
5use std::path::{Path, PathBuf};
6use zeroclaw_config::schema::LinkedInImageConfig;
7
8const LINKEDIN_API_BASE: &str = "https://api.linkedin.com";
9const LINKEDIN_OAUTH_TOKEN_URL: &str = "https://www.linkedin.com/oauth/v2/accessToken";
10const LINKEDIN_REQUEST_TIMEOUT_SECS: u64 = 30;
11const LINKEDIN_CONNECT_TIMEOUT_SECS: u64 = 10;
12
13pub struct LinkedInClient {
14 workspace_dir: PathBuf,
15 api_version: String,
16}
17
18#[derive(Debug)]
19pub struct LinkedInCredentials {
20 pub client_id: String,
21 pub client_secret: String,
22 pub access_token: String,
23 pub refresh_token: Option<String>,
24 pub person_id: String,
25}
26
27#[derive(Debug, serde::Serialize)]
28pub struct PostSummary {
29 pub id: String,
30 pub text: String,
31 pub created_at: String,
32 pub visibility: String,
33}
34
35#[derive(Debug, serde::Serialize)]
36pub struct ProfileInfo {
37 pub id: String,
38 pub name: String,
39 pub headline: String,
40}
41
42#[derive(Debug, serde::Serialize)]
43pub struct EngagementSummary {
44 pub likes: u64,
45 pub comments: u64,
46 pub shares: u64,
47}
48
49impl LinkedInClient {
50 pub fn new(workspace_dir: PathBuf, api_version: String) -> Self {
51 Self {
52 workspace_dir,
53 api_version,
54 }
55 }
56
57 fn parse_env_value(raw: &str) -> String {
58 let raw = raw.trim();
59
60 let unquoted = if raw.len() >= 2
61 && ((raw.starts_with('"') && raw.ends_with('"'))
62 || (raw.starts_with('\'') && raw.ends_with('\'')))
63 {
64 &raw[1..raw.len() - 1]
65 } else {
66 raw
67 };
68
69 unquoted.split_once(" #").map_or_else(
71 || unquoted.trim().to_string(),
72 |(value, _)| value.trim().to_string(),
73 )
74 }
75
76 pub async fn get_credentials(&self) -> anyhow::Result<LinkedInCredentials> {
77 let env_path = self.workspace_dir.join(".env");
78 let content = tokio::fs::read_to_string(&env_path)
79 .await
80 .with_context(|| format!("Failed to read {}", env_path.display()))?;
81
82 let mut client_id = None;
83 let mut client_secret = None;
84 let mut access_token = None;
85 let mut refresh_token = None;
86 let mut person_id = None;
87
88 for line in content.lines() {
89 let line = line.trim();
90 if line.starts_with('#') || line.is_empty() {
91 continue;
92 }
93 let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
94 if let Some((key, value)) = line.split_once('=') {
95 let key = key.trim();
96 let value = Self::parse_env_value(value);
97
98 match key {
99 "LINKEDIN_CLIENT_ID" => client_id = Some(value),
100 "LINKEDIN_CLIENT_SECRET" => client_secret = Some(value),
101 "LINKEDIN_ACCESS_TOKEN" => access_token = Some(value),
102 "LINKEDIN_REFRESH_TOKEN" if !value.is_empty() => refresh_token = Some(value),
103 "LINKEDIN_PERSON_ID" => person_id = Some(value),
104 _ => {}
105 }
106 }
107 }
108
109 let client_id = client_id.ok_or_else(|| {
110 ::zeroclaw_log::record!(
111 ERROR,
112 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
113 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
114 .with_attrs(::serde_json::json!({"missing": "LINKEDIN_CLIENT_ID"})),
115 "linkedin_client: LINKEDIN_CLIENT_ID missing from .env"
116 );
117 anyhow::Error::msg("LINKEDIN_CLIENT_ID not found in .env")
118 })?;
119 let client_secret = client_secret.ok_or_else(|| {
120 ::zeroclaw_log::record!(
121 ERROR,
122 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
123 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
124 .with_attrs(::serde_json::json!({"missing": "LINKEDIN_CLIENT_SECRET"})),
125 "linkedin_client: LINKEDIN_CLIENT_SECRET missing from .env"
126 );
127 anyhow::Error::msg("LINKEDIN_CLIENT_SECRET not found in .env")
128 })?;
129 let access_token = access_token.ok_or_else(|| {
130 ::zeroclaw_log::record!(
131 ERROR,
132 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
133 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
134 .with_attrs(::serde_json::json!({"missing": "LINKEDIN_ACCESS_TOKEN"})),
135 "linkedin_client: LINKEDIN_ACCESS_TOKEN missing from .env"
136 );
137 anyhow::Error::msg("LINKEDIN_ACCESS_TOKEN not found in .env")
138 })?;
139 let person_id = person_id.ok_or_else(|| {
140 ::zeroclaw_log::record!(
141 ERROR,
142 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
143 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
144 .with_attrs(::serde_json::json!({"missing": "LINKEDIN_PERSON_ID"})),
145 "linkedin_client: LINKEDIN_PERSON_ID missing from .env"
146 );
147 anyhow::Error::msg("LINKEDIN_PERSON_ID not found in .env")
148 })?;
149
150 Ok(LinkedInCredentials {
151 client_id,
152 client_secret,
153 access_token,
154 refresh_token,
155 person_id,
156 })
157 }
158
159 fn client() -> reqwest::Client {
160 zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
161 "tool.linkedin",
162 LINKEDIN_REQUEST_TIMEOUT_SECS,
163 LINKEDIN_CONNECT_TIMEOUT_SECS,
164 )
165 }
166
167 fn api_headers(&self, token: &str) -> HeaderMap {
168 let mut headers = HeaderMap::new();
169 let bearer = format!("Bearer {}", token);
170 headers.insert(
171 reqwest::header::AUTHORIZATION,
172 HeaderValue::from_str(&bearer).expect("valid bearer token header"),
173 );
174 headers.insert(
175 "LinkedIn-Version",
176 HeaderValue::from_str(&self.api_version).expect("valid api version header"),
177 );
178 headers.insert(
179 "X-Restli-Protocol-Version",
180 HeaderValue::from_static("2.0.0"),
181 );
182 headers
183 }
184
185 async fn api_request(
186 &self,
187 method: Method,
188 url: &str,
189 token: &str,
190 body: Option<serde_json::Value>,
191 ) -> anyhow::Result<reqwest::Response> {
192 let client = Self::client();
193 let headers = self.api_headers(token);
194
195 let mut req = client.request(method.clone(), url).headers(headers);
196 if let Some(ref json_body) = body {
197 req = req.json(json_body);
198 }
199
200 let response = req.send().await.context("LinkedIn API request failed")?;
201
202 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
203 let creds = self.get_credentials().await?;
205 let new_token = self.refresh_token(&creds).await?;
206 self.update_env_token(&new_token).await?;
207
208 let retry_headers = self.api_headers(&new_token);
209 let mut retry_req = Self::client().request(method, url).headers(retry_headers);
210 if let Some(json_body) = body {
211 retry_req = retry_req.json(&json_body);
212 }
213
214 let retry_response = retry_req
215 .send()
216 .await
217 .context("LinkedIn API retry request failed")?;
218
219 return Ok(retry_response);
220 }
221
222 Ok(response)
223 }
224
225 pub async fn create_post(
226 &self,
227 text: &str,
228 visibility: &str,
229 article_url: Option<&str>,
230 article_title: Option<&str>,
231 scheduled_at: Option<&str>,
232 ) -> anyhow::Result<String> {
233 let creds = self.get_credentials().await?;
234 let author_urn = format!("urn:li:person:{}", creds.person_id);
235
236 let lifecycle = if scheduled_at.is_some() {
237 "DRAFT"
238 } else {
239 "PUBLISHED"
240 };
241
242 let mut body = json!({
243 "author": author_urn,
244 "lifecycleState": lifecycle,
245 "visibility": visibility,
246 "commentary": text,
247 "distribution": {
248 "feedDistribution": "MAIN_FEED",
249 "targetEntities": [],
250 "thirdPartyDistributionChannels": []
251 }
252 });
253
254 if let Some(ts) = scheduled_at
257 && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
258 {
259 let epoch_ms = dt.timestamp_millis();
260 body.as_object_mut().unwrap().insert(
261 "scheduledPublishOptions".to_string(),
262 json!({ "scheduledPublishTime": epoch_ms }),
263 );
264 body["lifecycleState"] = json!("DRAFT");
266 }
267
268 if let Some(url) = article_url {
269 let mut article = json!({
270 "source": url,
271 "title": article_title.unwrap_or(""),
272 });
273 if article_title.is_none() || article_title.is_some_and(|t| t.is_empty()) {
274 article.as_object_mut().unwrap().remove("title");
275 }
276 body.as_object_mut().unwrap().insert(
277 "content".to_string(),
278 json!({
279 "article": {
280 "source": url,
281 "title": article_title.unwrap_or("")
282 }
283 }),
284 );
285 }
286
287 let url = format!("{}/rest/posts", LINKEDIN_API_BASE);
288 let response = self
289 .api_request(Method::POST, &url, &creds.access_token, Some(body))
290 .await?;
291
292 let status = response.status();
293 if !status.is_success() {
294 let body_text = response.text().await.unwrap_or_default();
295 anyhow::bail!("LinkedIn create_post failed ({}): {}", status, body_text);
296 }
297
298 let post_urn = response
300 .headers()
301 .get("x-restli-id")
302 .and_then(|v| v.to_str().ok())
303 .map(String::from)
304 .unwrap_or_default();
305
306 Ok(post_urn)
307 }
308
309 pub async fn list_posts(&self, count: usize) -> anyhow::Result<Vec<PostSummary>> {
310 let creds = self.get_credentials().await?;
311 let author_urn = format!("urn:li:person:{}", creds.person_id);
312 let url = format!(
313 "{}/rest/posts?author={}&q=author&count={}",
314 LINKEDIN_API_BASE, author_urn, count
315 );
316
317 let response = self
318 .api_request(Method::GET, &url, &creds.access_token, None)
319 .await?;
320
321 let status = response.status();
322 if !status.is_success() {
323 let body_text = response.text().await.unwrap_or_default();
324 anyhow::bail!("LinkedIn list_posts failed ({}): {}", status, body_text);
325 }
326
327 let json: serde_json::Value = response
328 .json()
329 .await
330 .context("Failed to parse list_posts response")?;
331
332 let elements = json
333 .get("elements")
334 .and_then(|e| e.as_array())
335 .cloned()
336 .unwrap_or_default();
337
338 let posts = elements
339 .iter()
340 .map(|el| PostSummary {
341 id: el
342 .get("id")
343 .and_then(|v| v.as_str())
344 .unwrap_or_default()
345 .to_string(),
346 text: el
347 .get("commentary")
348 .and_then(|v| v.as_str())
349 .unwrap_or_default()
350 .to_string(),
351 created_at: el
352 .get("createdAt")
353 .and_then(|v| v.as_u64())
354 .map(|ts| ts.to_string())
355 .unwrap_or_default(),
356 visibility: el
357 .get("visibility")
358 .and_then(|v| v.as_str())
359 .unwrap_or_default()
360 .to_string(),
361 })
362 .collect();
363
364 Ok(posts)
365 }
366
367 pub async fn add_comment(&self, post_id: &str, text: &str) -> anyhow::Result<String> {
368 let creds = self.get_credentials().await?;
369 let actor_urn = format!("urn:li:person:{}", creds.person_id);
370 let url = format!(
371 "{}/rest/socialActions/{}/comments",
372 LINKEDIN_API_BASE, post_id
373 );
374
375 let body = json!({
376 "actor": actor_urn,
377 "message": {
378 "text": text
379 }
380 });
381
382 let response = self
383 .api_request(Method::POST, &url, &creds.access_token, Some(body))
384 .await?;
385
386 let status = response.status();
387 if !status.is_success() {
388 let body_text = response.text().await.unwrap_or_default();
389 anyhow::bail!("LinkedIn add_comment failed ({}): {}", status, body_text);
390 }
391
392 let json: serde_json::Value = response
393 .json()
394 .await
395 .context("Failed to parse add_comment response")?;
396
397 let comment_id = json
398 .get("id")
399 .and_then(|v| v.as_str())
400 .unwrap_or_default()
401 .to_string();
402
403 Ok(comment_id)
404 }
405
406 pub async fn add_reaction(&self, post_id: &str, reaction_type: &str) -> anyhow::Result<()> {
407 let creds = self.get_credentials().await?;
408 let actor_urn = format!("urn:li:person:{}", creds.person_id);
409 let url = format!("{}/rest/reactions?actor={}", LINKEDIN_API_BASE, actor_urn);
410
411 let body = json!({
412 "reactionType": reaction_type,
413 "object": post_id
414 });
415
416 let response = self
417 .api_request(Method::POST, &url, &creds.access_token, Some(body))
418 .await?;
419
420 let status = response.status();
421 if !status.is_success() {
422 let body_text = response.text().await.unwrap_or_default();
423 anyhow::bail!("LinkedIn add_reaction failed ({}): {}", status, body_text);
424 }
425
426 Ok(())
427 }
428
429 pub async fn delete_post(&self, post_id: &str) -> anyhow::Result<()> {
430 let creds = self.get_credentials().await?;
431 let url = format!("{}/rest/posts/{}", LINKEDIN_API_BASE, post_id);
432
433 let response = self
434 .api_request(Method::DELETE, &url, &creds.access_token, None)
435 .await?;
436
437 let status = response.status();
438 if !status.is_success() {
439 let body_text = response.text().await.unwrap_or_default();
440 anyhow::bail!("LinkedIn delete_post failed ({}): {}", status, body_text);
441 }
442
443 Ok(())
444 }
445
446 pub async fn get_engagement(&self, post_id: &str) -> anyhow::Result<EngagementSummary> {
447 let creds = self.get_credentials().await?;
448 let url = format!("{}/rest/socialActions/{}", LINKEDIN_API_BASE, post_id);
449
450 let response = self
451 .api_request(Method::GET, &url, &creds.access_token, None)
452 .await?;
453
454 let status = response.status();
455 if !status.is_success() {
456 let body_text = response.text().await.unwrap_or_default();
457 anyhow::bail!("LinkedIn get_engagement failed ({}): {}", status, body_text);
458 }
459
460 let json: serde_json::Value = response
461 .json()
462 .await
463 .context("Failed to parse get_engagement response")?;
464
465 let likes = json
466 .get("likesSummary")
467 .and_then(|v| v.get("totalLikes"))
468 .and_then(|v| v.as_u64())
469 .unwrap_or(0);
470
471 let comments = json
472 .get("commentsSummary")
473 .and_then(|v| v.get("totalFirstLevelComments"))
474 .and_then(|v| v.as_u64())
475 .unwrap_or(0);
476
477 let shares = json
478 .get("sharesSummary")
479 .and_then(|v| v.get("totalShares"))
480 .and_then(|v| v.as_u64())
481 .unwrap_or(0);
482
483 Ok(EngagementSummary {
484 likes,
485 comments,
486 shares,
487 })
488 }
489
490 pub async fn get_profile(&self) -> anyhow::Result<ProfileInfo> {
491 let creds = self.get_credentials().await?;
492 let url = format!("{}/rest/me", LINKEDIN_API_BASE);
493
494 let response = self
495 .api_request(Method::GET, &url, &creds.access_token, None)
496 .await?;
497
498 let status = response.status();
499 if !status.is_success() {
500 let body_text = response.text().await.unwrap_or_default();
501 anyhow::bail!("LinkedIn get_profile failed ({}): {}", status, body_text);
502 }
503
504 let json: serde_json::Value = response
505 .json()
506 .await
507 .context("Failed to parse get_profile response")?;
508
509 let id = json
510 .get("id")
511 .and_then(|v| v.as_str())
512 .unwrap_or_default()
513 .to_string();
514
515 let first_name = json
516 .get("localizedFirstName")
517 .and_then(|v| v.as_str())
518 .unwrap_or_default();
519
520 let last_name = json
521 .get("localizedLastName")
522 .and_then(|v| v.as_str())
523 .unwrap_or_default();
524
525 let name = format!("{} {}", first_name, last_name).trim().to_string();
526
527 let headline = json
528 .get("localizedHeadline")
529 .and_then(|v| v.as_str())
530 .unwrap_or_default()
531 .to_string();
532
533 Ok(ProfileInfo { id, name, headline })
534 }
535
536 async fn refresh_token(&self, creds: &LinkedInCredentials) -> anyhow::Result<String> {
537 let refresh = creds
538 .refresh_token
539 .as_deref()
540 .filter(|t| !t.is_empty())
541 .ok_or_else(|| {
542 ::zeroclaw_log::record!(
543 WARN,
544 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
545 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
546 "linkedin_client: no refresh token available"
547 );
548 anyhow::Error::msg("No refresh token available")
549 })?;
550
551 let client = Self::client();
552 let response = client
553 .post(LINKEDIN_OAUTH_TOKEN_URL)
554 .form(&[
555 ("grant_type", "refresh_token"),
556 ("refresh_token", refresh),
557 ("client_id", &creds.client_id),
558 ("client_secret", &creds.client_secret),
559 ])
560 .send()
561 .await
562 .context("LinkedIn token refresh request failed")?;
563
564 let status = response.status();
565 if !status.is_success() {
566 let body_text = response.text().await.unwrap_or_default();
567 anyhow::bail!("LinkedIn token refresh failed ({}): {}", status, body_text);
568 }
569
570 let json: serde_json::Value = response
571 .json()
572 .await
573 .context("Failed to parse token refresh response")?;
574
575 let new_token = json
576 .get("access_token")
577 .and_then(|v| v.as_str())
578 .map(String::from)
579 .ok_or_else(|| {
580 ::zeroclaw_log::record!(
581 ERROR,
582 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
583 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
584 .with_attrs(::serde_json::json!({"field": "access_token"})),
585 "linkedin_client: token refresh response missing access_token"
586 );
587 anyhow::Error::msg("Token refresh response missing access_token field")
588 })?;
589
590 Ok(new_token)
591 }
592
593 pub async fn upload_image(
600 &self,
601 image_bytes: &[u8],
602 token: &str,
603 person_id: &str,
604 ) -> anyhow::Result<String> {
605 let owner_urn = format!("urn:li:person:{person_id}");
606
607 let register_body = json!({
609 "initializeUploadRequest": {
610 "owner": owner_urn
611 }
612 });
613 let register_url = format!("{LINKEDIN_API_BASE}/rest/images?action=initializeUpload");
614 let register_resp = self
615 .api_request(Method::POST, ®ister_url, token, Some(register_body))
616 .await?;
617
618 let status = register_resp.status();
619 if !status.is_success() {
620 let body_text = register_resp.text().await.unwrap_or_default();
621 anyhow::bail!("LinkedIn image register failed ({status}): {body_text}");
622 }
623
624 let register_json: serde_json::Value = register_resp
625 .json()
626 .await
627 .context("Failed to parse image register response")?;
628
629 let upload_url = register_json
630 .pointer("/value/uploadUrl")
631 .and_then(|v| v.as_str())
632 .ok_or_else(|| {
633 ::zeroclaw_log::record!(
634 ERROR,
635 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
636 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
637 .with_attrs(::serde_json::json!({"missing": "uploadUrl"})),
638 "linkedin_client: register response missing uploadUrl"
639 );
640 anyhow::Error::msg("Missing uploadUrl in register response")
641 })?
642 .to_string();
643
644 let image_urn = register_json
645 .pointer("/value/image")
646 .and_then(|v| v.as_str())
647 .ok_or_else(|| {
648 ::zeroclaw_log::record!(
649 ERROR,
650 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
651 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
652 .with_attrs(::serde_json::json!({"missing": "image_urn"})),
653 "linkedin_client: register response missing image URN"
654 );
655 anyhow::Error::msg("Missing image URN in register response")
656 })?
657 .to_string();
658
659 let client = Self::client();
661 let mut upload_headers = HeaderMap::new();
662 upload_headers.insert(
663 reqwest::header::AUTHORIZATION,
664 HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer token header"),
665 );
666
667 let upload_resp = client
668 .put(&upload_url)
669 .headers(upload_headers)
670 .header("Content-Type", "image/png")
671 .body(image_bytes.to_vec())
672 .send()
673 .await
674 .context("LinkedIn image upload failed")?;
675
676 let upload_status = upload_resp.status();
677 if !upload_status.is_success() {
678 let body_text = upload_resp.text().await.unwrap_or_default();
679 anyhow::bail!("LinkedIn image upload failed ({upload_status}): {body_text}");
680 }
681
682 Ok(image_urn)
683 }
684
685 pub async fn create_post_with_image(
687 &self,
688 text: &str,
689 visibility: &str,
690 image_urn: &str,
691 scheduled_at: Option<&str>,
692 ) -> anyhow::Result<String> {
693 let creds = self.get_credentials().await?;
694 let author_urn = format!("urn:li:person:{}", creds.person_id);
695
696 let lifecycle = if scheduled_at.is_some() {
697 "DRAFT"
698 } else {
699 "PUBLISHED"
700 };
701
702 let mut body = json!({
703 "author": author_urn,
704 "lifecycleState": lifecycle,
705 "visibility": visibility,
706 "commentary": text,
707 "distribution": {
708 "feedDistribution": "MAIN_FEED",
709 "targetEntities": [],
710 "thirdPartyDistributionChannels": []
711 },
712 "content": {
713 "media": {
714 "id": image_urn
715 }
716 }
717 });
718
719 if let Some(ts) = scheduled_at
720 && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
721 {
722 let epoch_ms = dt.timestamp_millis();
723 body.as_object_mut().unwrap().insert(
724 "scheduledPublishOptions".to_string(),
725 json!({ "scheduledPublishTime": epoch_ms }),
726 );
727 }
728
729 let url = format!("{LINKEDIN_API_BASE}/rest/posts");
730 let response = self
731 .api_request(Method::POST, &url, &creds.access_token, Some(body))
732 .await?;
733
734 let status = response.status();
735 if !status.is_success() {
736 let body_text = response.text().await.unwrap_or_default();
737 anyhow::bail!("LinkedIn create_post_with_image failed ({status}): {body_text}");
738 }
739
740 let post_urn = response
741 .headers()
742 .get("x-restli-id")
743 .and_then(|v| v.to_str().ok())
744 .map(String::from)
745 .unwrap_or_default();
746
747 Ok(post_urn)
748 }
749
750 async fn update_env_token(&self, new_token: &str) -> anyhow::Result<()> {
751 let env_path = self.workspace_dir.join(".env");
752 let content = tokio::fs::read_to_string(&env_path)
753 .await
754 .with_context(|| format!("Failed to read {}", env_path.display()))?;
755
756 let mut updated_lines: Vec<String> = Vec::new();
757 let mut found = false;
758
759 for line in content.lines() {
760 let trimmed = line.trim();
761
762 let is_token_line = if trimmed.starts_with('#') || trimmed.is_empty() {
764 false
765 } else {
766 let check = trimmed
767 .strip_prefix("export ")
768 .map(str::trim)
769 .unwrap_or(trimmed);
770 check
771 .split_once('=')
772 .is_some_and(|(key, _)| key.trim() == "LINKEDIN_ACCESS_TOKEN")
773 };
774
775 if is_token_line {
776 let has_export = trimmed.starts_with("export ");
778 let after_key = trimmed.strip_prefix("export ").unwrap_or(trimmed).trim();
779 let (_key, old_val) = after_key
780 .split_once('=')
781 .unwrap_or(("LINKEDIN_ACCESS_TOKEN", ""));
782 let old_val = old_val.trim();
783
784 let new_val = if old_val.starts_with('"') {
785 format!("\"{}\"", new_token)
786 } else if old_val.starts_with('\'') {
787 format!("'{}'", new_token)
788 } else {
789 new_token.to_string()
790 };
791
792 let new_line = if has_export {
793 format!("export LINKEDIN_ACCESS_TOKEN={}", new_val)
794 } else {
795 format!("LINKEDIN_ACCESS_TOKEN={}", new_val)
796 };
797
798 updated_lines.push(new_line);
799 found = true;
800 } else {
801 updated_lines.push(line.to_string());
802 }
803 }
804
805 if !found {
806 anyhow::bail!("LINKEDIN_ACCESS_TOKEN not found in .env for update");
807 }
808
809 let mut output = updated_lines.join("\n");
811 if content.ends_with('\n') {
812 output.push('\n');
813 }
814
815 tokio::fs::write(&env_path, &output)
816 .await
817 .with_context(|| format!("Failed to write {}", env_path.display()))?;
818
819 Ok(())
820 }
821}
822
823pub struct ImageGenerator {
830 config: LinkedInImageConfig,
831 workspace_dir: PathBuf,
832}
833
834impl ImageGenerator {
835 pub fn new(config: LinkedInImageConfig, workspace_dir: PathBuf) -> Self {
836 Self {
837 config,
838 workspace_dir,
839 }
840 }
841
842 pub async fn generate(&self, prompt: &str) -> anyhow::Result<PathBuf> {
844 let image_dir = self.workspace_dir.join(&self.config.temp_dir);
845 tokio::fs::create_dir_all(&image_dir).await?;
846
847 let timestamp = std::time::SystemTime::now()
848 .duration_since(std::time::UNIX_EPOCH)
849 .unwrap_or_default()
850 .as_secs();
851 let base_name = format!("post_{timestamp}");
852
853 for provider_name in &self.config.providers {
855 let result = match provider_name.as_str() {
856 "stability" => self.try_stability(prompt, &image_dir, &base_name).await,
857 "imagen" => self.try_imagen(prompt, &image_dir, &base_name).await,
858 "dalle" => self.try_dalle(prompt, &image_dir, &base_name).await,
859 "flux" => self.try_flux(prompt, &image_dir, &base_name).await,
860 other => {
861 ::zeroclaw_log::record!(
862 WARN,
863 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
864 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
865 .with_attrs(::serde_json::json!({"other": other})),
866 "Unknown image model_provider '', skipping"
867 );
868 continue;
869 }
870 };
871
872 match result {
873 Ok(path) => {
874 ::zeroclaw_log::record!(
875 INFO,
876 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
877 &format!("Image generated via {provider_name}: {}", path.display())
878 );
879 return Ok(path);
880 }
881 Err(e) => {
882 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error": format!("{}", e), "provider_name": provider_name})), "Image model_provider '' failed");
883 }
884 }
885 }
886
887 if self.config.fallback_card {
889 let svg_path = image_dir.join(format!("{base_name}.svg"));
890 let svg_content = Self::generate_fallback_card(prompt, &self.config.card_accent_color);
891 tokio::fs::write(&svg_path, &svg_content).await?;
892 ::zeroclaw_log::record!(
893 INFO,
894 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
895 &format!("Fallback SVG card generated: {}", svg_path.display())
896 );
897 return Ok(svg_path);
898 }
899
900 anyhow::bail!("All image generation model_providers failed and fallback_card is disabled")
901 }
902
903 async fn read_env_var(workspace_dir: &Path, var_name: &str) -> anyhow::Result<String> {
905 let env_path = workspace_dir.join(".env");
906 let content = tokio::fs::read_to_string(&env_path)
907 .await
908 .with_context(|| format!("Failed to read {}", env_path.display()))?;
909
910 for line in content.lines() {
911 let line = line.trim();
912 if line.starts_with('#') || line.is_empty() {
913 continue;
914 }
915 let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
916 if let Some((key, value)) = line.split_once('=')
917 && key.trim() == var_name
918 {
919 let val = LinkedInClient::parse_env_value(value);
920 if !val.is_empty() {
921 return Ok(val);
922 }
923 }
924 }
925
926 anyhow::bail!("{var_name} not found or empty in .env")
927 }
928
929 fn http_client() -> reqwest::Client {
930 zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
931 "tool.linkedin.image",
932 60, 10,
934 )
935 }
936
937 async fn try_stability(
940 &self,
941 prompt: &str,
942 output_dir: &Path,
943 base_name: &str,
944 ) -> anyhow::Result<PathBuf> {
945 let api_key =
946 Self::read_env_var(&self.workspace_dir, &self.config.stability.api_key_env).await?;
947
948 let client = Self::http_client();
949 let url = format!(
950 "https://api.stability.ai/v1/generation/{}/text-to-image",
951 self.config.stability.model
952 );
953
954 let body = json!({
955 "text_prompts": [{"text": prompt, "weight": 1.0}],
956 "cfg_scale": 7,
957 "height": 1024,
958 "width": 1024,
959 "samples": 1,
960 "steps": 30
961 });
962
963 let resp = client
964 .post(&url)
965 .header("Authorization", format!("Bearer {api_key}"))
966 .header("Content-Type", "application/json")
967 .header("Accept", "application/json")
968 .json(&body)
969 .send()
970 .await
971 .context("Stability AI request failed")?;
972
973 let status = resp.status();
974 if !status.is_success() {
975 let body_text = resp.text().await.unwrap_or_default();
976 anyhow::bail!("Stability AI failed ({status}): {body_text}");
977 }
978
979 let json: serde_json::Value = resp.json().await?;
980 let b64 = json
981 .pointer("/artifacts/0/base64")
982 .and_then(|v| v.as_str())
983 .ok_or_else(|| {
984 ::zeroclaw_log::record!(
985 ERROR,
986 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
987 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
988 .with_attrs(::serde_json::json!({"image_provider": "stability"})),
989 "linkedin_client: Stability response missing image data"
990 );
991 anyhow::Error::msg("No image data in Stability response")
992 })?;
993
994 let bytes = base64_decode(b64)?;
995 let path = output_dir.join(format!("{base_name}_stability.png"));
996 tokio::fs::write(&path, &bytes).await?;
997 Ok(path)
998 }
999
1000 async fn try_imagen(
1003 &self,
1004 prompt: &str,
1005 output_dir: &Path,
1006 base_name: &str,
1007 ) -> anyhow::Result<PathBuf> {
1008 let api_key =
1009 Self::read_env_var(&self.workspace_dir, &self.config.imagen.api_key_env).await?;
1010 let project_id =
1011 Self::read_env_var(&self.workspace_dir, &self.config.imagen.project_id_env).await?;
1012
1013 let client = Self::http_client();
1014 let url = format!(
1015 "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/imagen-3.0-generate-001:predict",
1016 self.config.imagen.region, project_id, self.config.imagen.region
1017 );
1018
1019 let body = json!({
1020 "instances": [{"prompt": prompt}],
1021 "parameters": {
1022 "sampleCount": 1,
1023 "aspectRatio": "1:1"
1024 }
1025 });
1026
1027 let resp = client
1028 .post(&url)
1029 .header("Authorization", format!("Bearer {api_key}"))
1030 .header("Content-Type", "application/json")
1031 .json(&body)
1032 .send()
1033 .await
1034 .context("Imagen request failed")?;
1035
1036 let status = resp.status();
1037 if !status.is_success() {
1038 let body_text = resp.text().await.unwrap_or_default();
1039 anyhow::bail!("Imagen failed ({status}): {body_text}");
1040 }
1041
1042 let json: serde_json::Value = resp.json().await?;
1043 let b64 = json
1044 .pointer("/predictions/0/bytesBase64Encoded")
1045 .and_then(|v| v.as_str())
1046 .ok_or_else(|| {
1047 ::zeroclaw_log::record!(
1048 ERROR,
1049 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1050 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1051 .with_attrs(::serde_json::json!({"image_provider": "imagen"})),
1052 "linkedin_client: Imagen response missing image data"
1053 );
1054 anyhow::Error::msg("No image data in Imagen response")
1055 })?;
1056
1057 let bytes = base64_decode(b64)?;
1058 let path = output_dir.join(format!("{base_name}_imagen.png"));
1059 tokio::fs::write(&path, &bytes).await?;
1060 Ok(path)
1061 }
1062
1063 async fn try_dalle(
1066 &self,
1067 prompt: &str,
1068 output_dir: &Path,
1069 base_name: &str,
1070 ) -> anyhow::Result<PathBuf> {
1071 let api_key =
1072 Self::read_env_var(&self.workspace_dir, &self.config.dalle.api_key_env).await?;
1073
1074 let client = Self::http_client();
1075 let url = "https://api.openai.com/v1/images/generations";
1076
1077 let body = json!({
1078 "model": self.config.dalle.model,
1079 "prompt": prompt,
1080 "n": 1,
1081 "size": self.config.dalle.size,
1082 "response_format": "b64_json"
1083 });
1084
1085 let resp = client
1086 .post(url)
1087 .header("Authorization", format!("Bearer {api_key}"))
1088 .header("Content-Type", "application/json")
1089 .json(&body)
1090 .send()
1091 .await
1092 .context("DALL-E request failed")?;
1093
1094 let status = resp.status();
1095 if !status.is_success() {
1096 let body_text = resp.text().await.unwrap_or_default();
1097 anyhow::bail!("DALL-E failed ({status}): {body_text}");
1098 }
1099
1100 let json: serde_json::Value = resp.json().await?;
1101 let b64 = json
1102 .pointer("/data/0/b64_json")
1103 .and_then(|v| v.as_str())
1104 .ok_or_else(|| {
1105 ::zeroclaw_log::record!(
1106 ERROR,
1107 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1108 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1109 .with_attrs(::serde_json::json!({"image_provider": "dalle"})),
1110 "linkedin_client: DALL-E response missing image data"
1111 );
1112 anyhow::Error::msg("No image data in DALL-E response")
1113 })?;
1114
1115 let bytes = base64_decode(b64)?;
1116 let path = output_dir.join(format!("{base_name}_dalle.png"));
1117 tokio::fs::write(&path, &bytes).await?;
1118 Ok(path)
1119 }
1120
1121 async fn try_flux(
1124 &self,
1125 prompt: &str,
1126 output_dir: &Path,
1127 base_name: &str,
1128 ) -> anyhow::Result<PathBuf> {
1129 let api_key =
1130 Self::read_env_var(&self.workspace_dir, &self.config.flux.api_key_env).await?;
1131
1132 let client = Self::http_client();
1133 let url = format!("https://fal.run/{}", self.config.flux.model);
1134
1135 let body = json!({
1136 "prompt": prompt,
1137 "image_size": "square_hd",
1138 "num_images": 1
1139 });
1140
1141 let resp = client
1142 .post(&url)
1143 .header("Authorization", format!("Key {api_key}"))
1144 .header("Content-Type", "application/json")
1145 .json(&body)
1146 .send()
1147 .await
1148 .context("Flux request failed")?;
1149
1150 let status = resp.status();
1151 if !status.is_success() {
1152 let body_text = resp.text().await.unwrap_or_default();
1153 anyhow::bail!("Flux failed ({status}): {body_text}");
1154 }
1155
1156 let json: serde_json::Value = resp.json().await?;
1157 let image_url = json
1158 .pointer("/images/0/url")
1159 .and_then(|v| v.as_str())
1160 .ok_or_else(|| {
1161 ::zeroclaw_log::record!(
1162 ERROR,
1163 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1164 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1165 .with_attrs(::serde_json::json!({"image_provider": "flux"})),
1166 "linkedin_client: Flux response missing image URL"
1167 );
1168 anyhow::Error::msg("No image URL in Flux response")
1169 })?;
1170
1171 let img_resp = client.get(image_url).send().await?;
1173 if !img_resp.status().is_success() {
1174 anyhow::bail!("Failed to download Flux image from {image_url}");
1175 }
1176 let bytes = img_resp.bytes().await?;
1177 let path = output_dir.join(format!("{base_name}_flux.png"));
1178 tokio::fs::write(&path, &bytes).await?;
1179 Ok(path)
1180 }
1181
1182 pub fn generate_fallback_card(title: &str, accent_color: &str) -> String {
1186 let display_title = if title.len() > 80 {
1188 format!("{}...", &title[..77])
1189 } else {
1190 title.to_string()
1191 };
1192
1193 let lines = word_wrap(&display_title, 35, 3);
1195 let line_height: i32 = 48;
1196 #[allow(clippy::cast_possible_truncation)]
1198 let line_count: i32 = lines.len() as i32;
1199 let total_text_height = line_count * line_height;
1200 let start_y = (1024 - total_text_height) / 2 + 24;
1201
1202 let font = "system-ui, sans-serif";
1203 let text_elements: String = lines
1204 .iter()
1205 .enumerate()
1206 .map(|(i, line)| {
1207 #[allow(clippy::cast_possible_truncation)]
1208 let y = start_y + (i as i32 * line_height); format!(
1210 " <text x=\"512\" y=\"{y}\" text-anchor=\"middle\" fill=\"white\" \
1211 font-family=\"{font}\" font-size=\"36\" font-weight=\"600\">{}</text>",
1212 xml_escape(line)
1213 )
1214 })
1215 .collect::<Vec<_>>()
1216 .join("\n");
1217
1218 format!(
1219 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1024\" height=\"1024\" \
1220 viewBox=\"0 0 1024 1024\">\n\
1221 \x20 <defs>\n\
1222 \x20 <linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n\
1223 \x20 <stop offset=\"0%\" stop-color=\"{accent_color}\"/>\n\
1224 \x20 <stop offset=\"100%\" stop-color=\"#1a1a2e\"/>\n\
1225 \x20 </linearGradient>\n\
1226 \x20 </defs>\n\
1227 \x20 <rect width=\"1024\" height=\"1024\" fill=\"url(#bg)\" rx=\"0\"/>\n\
1228 \x20 <rect x=\"60\" y=\"60\" width=\"904\" height=\"904\" rx=\"24\" \
1229 fill=\"none\" stroke=\"rgba(255,255,255,0.15)\" stroke-width=\"2\"/>\n\
1230 {text_elements}\n\
1231 \x20 <text x=\"512\" y=\"920\" text-anchor=\"middle\" \
1232 fill=\"rgba(255,255,255,0.5)\" font-family=\"{font}\" \
1233 font-size=\"18\">ZeroClaw</text>\n\
1234 </svg>"
1235 )
1236 }
1237
1238 pub async fn cleanup(path: &Path) -> anyhow::Result<()> {
1240 if path.exists() {
1241 tokio::fs::remove_file(path).await?;
1242 }
1243 Ok(())
1244 }
1245}
1246
1247fn base64_decode(input: &str) -> anyhow::Result<Vec<u8>> {
1249 use base64::Engine;
1250 base64::engine::general_purpose::STANDARD
1251 .decode(input)
1252 .context("Failed to decode base64 image data")
1253}
1254
1255fn word_wrap(text: &str, max_width: usize, max_lines: usize) -> Vec<String> {
1257 let mut lines = Vec::new();
1258 let mut current_line = String::new();
1259
1260 for word in text.split_whitespace() {
1261 if current_line.is_empty() {
1262 current_line = word.to_string();
1263 } else if current_line.len() + 1 + word.len() <= max_width {
1264 current_line.push(' ');
1265 current_line.push_str(word);
1266 } else {
1267 lines.push(current_line);
1268 current_line = word.to_string();
1269 if lines.len() >= max_lines {
1270 break;
1271 }
1272 }
1273 }
1274
1275 if !current_line.is_empty() && lines.len() < max_lines {
1276 lines.push(current_line);
1277 }
1278
1279 lines
1280}
1281
1282fn xml_escape(text: &str) -> String {
1284 text.replace('&', "&")
1285 .replace('<', "<")
1286 .replace('>', ">")
1287 .replace('"', """)
1288 .replace('\'', "'")
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294 use std::fs;
1295 use tempfile::TempDir;
1296
1297 #[tokio::test]
1298 async fn credentials_parsed_plain_values() {
1299 let tmp = TempDir::new().unwrap();
1300 let env_path = tmp.path().join(".env");
1301 fs::write(
1302 &env_path,
1303 "LINKEDIN_CLIENT_ID=cid123\n\
1304 LINKEDIN_CLIENT_SECRET=csecret456\n\
1305 LINKEDIN_ACCESS_TOKEN=tok789\n\
1306 LINKEDIN_PERSON_ID=person001\n",
1307 )
1308 .unwrap();
1309
1310 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1311 let creds = client.get_credentials().await.unwrap();
1312
1313 assert_eq!(creds.client_id, "cid123");
1314 assert_eq!(creds.client_secret, "csecret456");
1315 assert_eq!(creds.access_token, "tok789");
1316 assert_eq!(creds.person_id, "person001");
1317 assert!(creds.refresh_token.is_none());
1318 }
1319
1320 #[tokio::test]
1321 async fn credentials_parsed_with_double_quotes() {
1322 let tmp = TempDir::new().unwrap();
1323 let env_path = tmp.path().join(".env");
1324 fs::write(
1325 &env_path,
1326 "LINKEDIN_CLIENT_ID=\"cid_quoted\"\n\
1327 LINKEDIN_CLIENT_SECRET=\"csecret_quoted\"\n\
1328 LINKEDIN_ACCESS_TOKEN=\"tok_quoted\"\n\
1329 LINKEDIN_PERSON_ID=\"person_quoted\"\n",
1330 )
1331 .unwrap();
1332
1333 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1334 let creds = client.get_credentials().await.unwrap();
1335
1336 assert_eq!(creds.client_id, "cid_quoted");
1337 assert_eq!(creds.client_secret, "csecret_quoted");
1338 assert_eq!(creds.access_token, "tok_quoted");
1339 assert_eq!(creds.person_id, "person_quoted");
1340 }
1341
1342 #[tokio::test]
1343 async fn credentials_parsed_with_single_quotes() {
1344 let tmp = TempDir::new().unwrap();
1345 let env_path = tmp.path().join(".env");
1346 fs::write(
1347 &env_path,
1348 "LINKEDIN_CLIENT_ID='cid_sq'\n\
1349 LINKEDIN_CLIENT_SECRET='csecret_sq'\n\
1350 LINKEDIN_ACCESS_TOKEN='tok_sq'\n\
1351 LINKEDIN_PERSON_ID='person_sq'\n",
1352 )
1353 .unwrap();
1354
1355 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1356 let creds = client.get_credentials().await.unwrap();
1357
1358 assert_eq!(creds.client_id, "cid_sq");
1359 assert_eq!(creds.access_token, "tok_sq");
1360 }
1361
1362 #[tokio::test]
1363 async fn credentials_parsed_with_export_prefix() {
1364 let tmp = TempDir::new().unwrap();
1365 let env_path = tmp.path().join(".env");
1366 fs::write(
1367 &env_path,
1368 "export LINKEDIN_CLIENT_ID=cid_exp\n\
1369 export LINKEDIN_CLIENT_SECRET=\"csecret_exp\"\n\
1370 export LINKEDIN_ACCESS_TOKEN='tok_exp'\n\
1371 export LINKEDIN_PERSON_ID=person_exp\n",
1372 )
1373 .unwrap();
1374
1375 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1376 let creds = client.get_credentials().await.unwrap();
1377
1378 assert_eq!(creds.client_id, "cid_exp");
1379 assert_eq!(creds.client_secret, "csecret_exp");
1380 assert_eq!(creds.access_token, "tok_exp");
1381 assert_eq!(creds.person_id, "person_exp");
1382 }
1383
1384 #[tokio::test]
1385 async fn credentials_ignore_comments_and_blanks() {
1386 let tmp = TempDir::new().unwrap();
1387 let env_path = tmp.path().join(".env");
1388 fs::write(
1389 &env_path,
1390 "# LinkedIn credentials\n\
1391 \n\
1392 LINKEDIN_CLIENT_ID=cid_c\n\
1393 # secret below\n\
1394 LINKEDIN_CLIENT_SECRET=csecret_c\n\
1395 LINKEDIN_ACCESS_TOKEN=tok_c # inline comment\n\
1396 LINKEDIN_PERSON_ID=person_c\n",
1397 )
1398 .unwrap();
1399
1400 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1401 let creds = client.get_credentials().await.unwrap();
1402
1403 assert_eq!(creds.client_id, "cid_c");
1404 assert_eq!(creds.client_secret, "csecret_c");
1405 assert_eq!(creds.access_token, "tok_c");
1406 assert_eq!(creds.person_id, "person_c");
1407 }
1408
1409 #[tokio::test]
1410 async fn credentials_with_refresh_token() {
1411 let tmp = TempDir::new().unwrap();
1412 let env_path = tmp.path().join(".env");
1413 fs::write(
1414 &env_path,
1415 "LINKEDIN_CLIENT_ID=cid\n\
1416 LINKEDIN_CLIENT_SECRET=csecret\n\
1417 LINKEDIN_ACCESS_TOKEN=tok\n\
1418 LINKEDIN_REFRESH_TOKEN=refresh123\n\
1419 LINKEDIN_PERSON_ID=person\n",
1420 )
1421 .unwrap();
1422
1423 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1424 let creds = client.get_credentials().await.unwrap();
1425
1426 assert_eq!(creds.refresh_token.as_deref(), Some("refresh123"));
1427 }
1428
1429 #[tokio::test]
1430 async fn credentials_empty_refresh_token_becomes_none() {
1431 let tmp = TempDir::new().unwrap();
1432 let env_path = tmp.path().join(".env");
1433 fs::write(
1434 &env_path,
1435 "LINKEDIN_CLIENT_ID=cid\n\
1436 LINKEDIN_CLIENT_SECRET=csecret\n\
1437 LINKEDIN_ACCESS_TOKEN=tok\n\
1438 LINKEDIN_REFRESH_TOKEN=\n\
1439 LINKEDIN_PERSON_ID=person\n",
1440 )
1441 .unwrap();
1442
1443 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1444 let creds = client.get_credentials().await.unwrap();
1445
1446 assert!(creds.refresh_token.is_none());
1447 }
1448
1449 #[tokio::test]
1450 async fn credentials_fail_missing_client_id() {
1451 let tmp = TempDir::new().unwrap();
1452 let env_path = tmp.path().join(".env");
1453 fs::write(
1454 &env_path,
1455 "LINKEDIN_CLIENT_SECRET=csecret\n\
1456 LINKEDIN_ACCESS_TOKEN=tok\n\
1457 LINKEDIN_PERSON_ID=person\n",
1458 )
1459 .unwrap();
1460
1461 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1462 let err = client.get_credentials().await.unwrap_err();
1463 assert!(err.to_string().contains("LINKEDIN_CLIENT_ID"));
1464 }
1465
1466 #[tokio::test]
1467 async fn credentials_fail_missing_access_token() {
1468 let tmp = TempDir::new().unwrap();
1469 let env_path = tmp.path().join(".env");
1470 fs::write(
1471 &env_path,
1472 "LINKEDIN_CLIENT_ID=cid\n\
1473 LINKEDIN_CLIENT_SECRET=csecret\n\
1474 LINKEDIN_PERSON_ID=person\n",
1475 )
1476 .unwrap();
1477
1478 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1479 let err = client.get_credentials().await.unwrap_err();
1480 assert!(err.to_string().contains("LINKEDIN_ACCESS_TOKEN"));
1481 }
1482
1483 #[tokio::test]
1484 async fn credentials_fail_missing_person_id() {
1485 let tmp = TempDir::new().unwrap();
1486 let env_path = tmp.path().join(".env");
1487 fs::write(
1488 &env_path,
1489 "LINKEDIN_CLIENT_ID=cid\n\
1490 LINKEDIN_CLIENT_SECRET=csecret\n\
1491 LINKEDIN_ACCESS_TOKEN=tok\n",
1492 )
1493 .unwrap();
1494
1495 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1496 let err = client.get_credentials().await.unwrap_err();
1497 assert!(err.to_string().contains("LINKEDIN_PERSON_ID"));
1498 }
1499
1500 #[tokio::test]
1501 async fn credentials_fail_no_env_file() {
1502 let tmp = TempDir::new().unwrap();
1503 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1504 let err = client.get_credentials().await.unwrap_err();
1505 assert!(err.to_string().contains("Failed to read"));
1506 }
1507
1508 #[tokio::test]
1509 async fn update_env_token_preserves_other_keys() {
1510 let tmp = TempDir::new().unwrap();
1511 let env_path = tmp.path().join(".env");
1512 fs::write(
1513 &env_path,
1514 "# Config\n\
1515 LINKEDIN_CLIENT_ID=cid\n\
1516 LINKEDIN_CLIENT_SECRET=csecret\n\
1517 LINKEDIN_ACCESS_TOKEN=old_token\n\
1518 LINKEDIN_PERSON_ID=person\n\
1519 OTHER_KEY=keepme\n",
1520 )
1521 .unwrap();
1522
1523 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1524 client.update_env_token("new_token_value").await.unwrap();
1525
1526 let updated = fs::read_to_string(&env_path).unwrap();
1527 assert!(updated.contains("LINKEDIN_ACCESS_TOKEN=new_token_value"));
1528 assert!(updated.contains("LINKEDIN_CLIENT_ID=cid"));
1529 assert!(updated.contains("LINKEDIN_CLIENT_SECRET=csecret"));
1530 assert!(updated.contains("LINKEDIN_PERSON_ID=person"));
1531 assert!(updated.contains("OTHER_KEY=keepme"));
1532 assert!(updated.contains("# Config"));
1533 assert!(!updated.contains("old_token"));
1534 }
1535
1536 #[tokio::test]
1537 async fn update_env_token_preserves_export_prefix() {
1538 let tmp = TempDir::new().unwrap();
1539 let env_path = tmp.path().join(".env");
1540 fs::write(
1541 &env_path,
1542 "export LINKEDIN_CLIENT_ID=cid\n\
1543 export LINKEDIN_CLIENT_SECRET=csecret\n\
1544 export LINKEDIN_ACCESS_TOKEN=\"old_tok\"\n\
1545 export LINKEDIN_PERSON_ID=person\n",
1546 )
1547 .unwrap();
1548
1549 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1550 client.update_env_token("refreshed_tok").await.unwrap();
1551
1552 let updated = fs::read_to_string(&env_path).unwrap();
1553 assert!(updated.contains("export LINKEDIN_ACCESS_TOKEN=\"refreshed_tok\""));
1554 assert!(updated.contains("export LINKEDIN_CLIENT_ID=cid"));
1555 }
1556
1557 #[tokio::test]
1558 async fn update_env_token_preserves_single_quote_style() {
1559 let tmp = TempDir::new().unwrap();
1560 let env_path = tmp.path().join(".env");
1561 fs::write(
1562 &env_path,
1563 "LINKEDIN_CLIENT_ID=cid\n\
1564 LINKEDIN_CLIENT_SECRET=csecret\n\
1565 LINKEDIN_ACCESS_TOKEN='old'\n\
1566 LINKEDIN_PERSON_ID=person\n",
1567 )
1568 .unwrap();
1569
1570 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1571 client.update_env_token("new_sq").await.unwrap();
1572
1573 let updated = fs::read_to_string(&env_path).unwrap();
1574 assert!(updated.contains("LINKEDIN_ACCESS_TOKEN='new_sq'"));
1575 }
1576
1577 #[tokio::test]
1578 async fn update_env_token_fails_if_key_missing() {
1579 let tmp = TempDir::new().unwrap();
1580 let env_path = tmp.path().join(".env");
1581 fs::write(
1582 &env_path,
1583 "LINKEDIN_CLIENT_ID=cid\n\
1584 LINKEDIN_PERSON_ID=person\n",
1585 )
1586 .unwrap();
1587
1588 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1589 let err = client.update_env_token("tok").await.unwrap_err();
1590 assert!(err.to_string().contains("LINKEDIN_ACCESS_TOKEN not found"));
1591 }
1592
1593 #[test]
1594 fn parse_env_value_strips_double_quotes() {
1595 assert_eq!(LinkedInClient::parse_env_value("\"hello\""), "hello");
1596 }
1597
1598 #[test]
1599 fn parse_env_value_strips_single_quotes() {
1600 assert_eq!(LinkedInClient::parse_env_value("'hello'"), "hello");
1601 }
1602
1603 #[test]
1604 fn parse_env_value_strips_inline_comment() {
1605 assert_eq!(LinkedInClient::parse_env_value("value # comment"), "value");
1606 }
1607
1608 #[test]
1609 fn parse_env_value_trims_whitespace() {
1610 assert_eq!(LinkedInClient::parse_env_value(" spaced "), "spaced");
1611 }
1612
1613 #[test]
1614 fn parse_env_value_plain() {
1615 assert_eq!(LinkedInClient::parse_env_value("plain"), "plain");
1616 }
1617
1618 #[test]
1619 fn api_headers_contains_required_headers() {
1620 let tmp = TempDir::new().unwrap();
1621 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1622 let headers = client.api_headers("test_token");
1623 assert_eq!(
1624 headers.get("Authorization").unwrap().to_str().unwrap(),
1625 "Bearer test_token"
1626 );
1627 assert_eq!(
1628 headers.get("LinkedIn-Version").unwrap().to_str().unwrap(),
1629 "202602"
1630 );
1631 assert_eq!(
1632 headers
1633 .get("X-Restli-Protocol-Version")
1634 .unwrap()
1635 .to_str()
1636 .unwrap(),
1637 "2.0.0"
1638 );
1639 }
1640
1641 #[test]
1644 fn fallback_card_contains_svg_structure() {
1645 let svg = ImageGenerator::generate_fallback_card("Test Title", "#0A66C2");
1646 assert!(svg.starts_with("<svg"));
1647 assert!(svg.contains("1024"));
1648 assert!(svg.contains("#0A66C2"));
1649 assert!(svg.contains("Test Title"));
1650 assert!(svg.contains("ZeroClaw"));
1651 }
1652
1653 #[test]
1654 fn fallback_card_escapes_xml_characters() {
1655 let svg =
1656 ImageGenerator::generate_fallback_card("AI & ML <Trends> for \"2026\"", "#0A66C2");
1657 assert!(svg.contains("&"));
1658 assert!(svg.contains("<"));
1659 assert!(svg.contains(">"));
1660 assert!(svg.contains("""));
1661 assert!(!svg.contains("& "));
1662 }
1663
1664 #[test]
1665 fn fallback_card_truncates_long_titles() {
1666 let long_title = "A".repeat(100);
1667 let svg = ImageGenerator::generate_fallback_card(&long_title, "#0A66C2");
1668 assert!(svg.contains("..."));
1669 assert!(!svg.contains(&long_title));
1671 }
1672
1673 #[test]
1674 fn fallback_card_uses_custom_accent_color() {
1675 let svg = ImageGenerator::generate_fallback_card("Title", "#FF5733");
1676 assert!(svg.contains("#FF5733"));
1677 assert!(!svg.contains("#0A66C2"));
1678 }
1679
1680 #[test]
1681 fn word_wrap_basic() {
1682 let lines = word_wrap("Hello world this is a test", 15, 3);
1683 assert_eq!(lines.len(), 2);
1684 assert_eq!(lines[0], "Hello world");
1685 assert_eq!(lines[1], "this is a test");
1686 }
1687
1688 #[test]
1689 fn word_wrap_respects_max_lines() {
1690 let lines = word_wrap("one two three four five six seven eight", 10, 2);
1691 assert!(lines.len() <= 2);
1692 }
1693
1694 #[test]
1695 fn word_wrap_single_word() {
1696 let lines = word_wrap("Hello", 35, 3);
1697 assert_eq!(lines.len(), 1);
1698 assert_eq!(lines[0], "Hello");
1699 }
1700
1701 #[test]
1702 fn word_wrap_empty() {
1703 let lines = word_wrap("", 35, 3);
1704 assert!(lines.is_empty());
1705 }
1706
1707 #[test]
1708 fn xml_escape_handles_all_special_chars() {
1709 assert_eq!(xml_escape("a&b"), "a&b");
1710 assert_eq!(xml_escape("a<b>c"), "a<b>c");
1711 assert_eq!(xml_escape("a\"b'c"), "a"b'c");
1712 }
1713
1714 #[test]
1715 fn xml_escape_preserves_normal_text() {
1716 assert_eq!(xml_escape("hello world 123"), "hello world 123");
1717 }
1718
1719 #[tokio::test]
1720 async fn image_generator_fallback_creates_svg_file() {
1721 let tmp = TempDir::new().unwrap();
1722 let config = LinkedInImageConfig {
1723 enabled: true,
1724 providers: vec![], fallback_card: true,
1726 card_accent_color: "#0A66C2".into(),
1727 temp_dir: "images".into(),
1728 ..Default::default()
1729 };
1730
1731 let generator = ImageGenerator::new(config, tmp.path().to_path_buf());
1732 let path = generator.generate("Test post about Rust").await.unwrap();
1733
1734 assert!(path.exists());
1735 assert_eq!(path.extension().unwrap(), "svg");
1736
1737 let content = fs::read_to_string(&path).unwrap();
1738 assert!(content.contains("Test post about Rust"));
1739 }
1740
1741 #[tokio::test]
1742 async fn image_generator_fails_when_no_providers_and_no_fallback() {
1743 let tmp = TempDir::new().unwrap();
1744 let config = LinkedInImageConfig {
1745 enabled: true,
1746 providers: vec![],
1747 fallback_card: false, ..Default::default()
1749 };
1750
1751 let generator = ImageGenerator::new(config, tmp.path().to_path_buf());
1752 let result = generator.generate("Test").await;
1753 assert!(result.is_err());
1754 assert!(
1755 result
1756 .unwrap_err()
1757 .to_string()
1758 .contains("All image generation model_providers failed")
1759 );
1760 }
1761
1762 #[tokio::test]
1763 async fn image_generator_skips_provider_without_key() {
1764 let tmp = TempDir::new().unwrap();
1765 fs::write(tmp.path().join(".env"), "SOME_OTHER_KEY=value\n").unwrap();
1767
1768 let config = LinkedInImageConfig {
1769 enabled: true,
1770 providers: vec!["stability".into(), "dalle".into()],
1771 fallback_card: true,
1772 temp_dir: "images".into(),
1773 ..Default::default()
1774 };
1775
1776 let generator = ImageGenerator::new(config, tmp.path().to_path_buf());
1777 let path = generator.generate("Test").await.unwrap();
1778
1779 assert_eq!(path.extension().unwrap(), "svg");
1781 }
1782
1783 #[tokio::test]
1784 async fn image_generator_cleanup_removes_file() {
1785 let tmp = TempDir::new().unwrap();
1786 let file_path = tmp.path().join("test.png");
1787 fs::write(&file_path, b"fake image data").unwrap();
1788 assert!(file_path.exists());
1789
1790 ImageGenerator::cleanup(&file_path).await.unwrap();
1791 assert!(!file_path.exists());
1792 }
1793
1794 #[tokio::test]
1795 async fn image_generator_cleanup_noop_for_missing_file() {
1796 let tmp = TempDir::new().unwrap();
1797 let file_path = tmp.path().join("nonexistent.png");
1798 ImageGenerator::cleanup(&file_path).await.unwrap();
1800 }
1801
1802 #[tokio::test]
1803 async fn read_env_var_reads_value() {
1804 let tmp = TempDir::new().unwrap();
1805 fs::write(
1806 tmp.path().join(".env"),
1807 "STABILITY_API_KEY=sk-test-123\nOTHER=val\n",
1808 )
1809 .unwrap();
1810
1811 let val = ImageGenerator::read_env_var(tmp.path(), "STABILITY_API_KEY")
1812 .await
1813 .unwrap();
1814 assert_eq!(val, "sk-test-123");
1815 }
1816
1817 #[tokio::test]
1818 async fn read_env_var_fails_for_missing_key() {
1819 let tmp = TempDir::new().unwrap();
1820 fs::write(tmp.path().join(".env"), "OTHER=val\n").unwrap();
1821
1822 let result = ImageGenerator::read_env_var(tmp.path(), "STABILITY_API_KEY").await;
1823 assert!(result.is_err());
1824 assert!(
1825 result
1826 .unwrap_err()
1827 .to_string()
1828 .contains("STABILITY_API_KEY")
1829 );
1830 }
1831
1832 #[test]
1833 fn image_config_default_has_all_providers() {
1834 let config = LinkedInImageConfig::default();
1835 assert_eq!(config.providers.len(), 4);
1836 assert_eq!(config.providers[0], "stability");
1837 assert_eq!(config.providers[1], "imagen");
1838 assert_eq!(config.providers[2], "dalle");
1839 assert_eq!(config.providers[3], "flux");
1840 assert!(config.fallback_card);
1841 assert!(!config.enabled);
1842 }
1843}