1use super::linkedin_client::{ImageGenerator, LinkedInClient};
2use async_trait::async_trait;
3use serde_json::json;
4use std::path::PathBuf;
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8use zeroclaw_config::schema::{LinkedInContentConfig, LinkedInImageConfig};
9
10pub struct LinkedInTool {
11 security: Arc<SecurityPolicy>,
12 workspace_dir: PathBuf,
13 api_version: String,
14 content_config: LinkedInContentConfig,
15 image_config: LinkedInImageConfig,
16}
17
18impl LinkedInTool {
19 pub fn new(
20 security: Arc<SecurityPolicy>,
21 workspace_dir: PathBuf,
22 api_version: String,
23 content_config: LinkedInContentConfig,
24 image_config: LinkedInImageConfig,
25 ) -> Self {
26 Self {
27 security,
28 workspace_dir,
29 api_version,
30 content_config,
31 image_config,
32 }
33 }
34
35 fn is_write_action(action: &str) -> bool {
36 matches!(action, "create_post" | "comment" | "react" | "delete_post")
37 }
38
39 fn build_content_strategy_summary(&self) -> String {
40 let c = &self.content_config;
41 let mut parts = Vec::new();
42
43 if !c.persona.is_empty() {
44 parts.push(format!("## Persona\n{}", c.persona));
45 }
46
47 if !c.topics.is_empty() {
48 parts.push(format!("## Topics\n{}", c.topics.join(", ")));
49 }
50
51 if !c.rss_feeds.is_empty() {
52 let feeds: Vec<String> = c.rss_feeds.iter().map(|f| format!("- {f}")).collect();
53 parts.push(format!(
54 "## RSS Feeds (fetch titles only for inspiration)\n{}",
55 feeds.join("\n")
56 ));
57 }
58
59 if !c.github_users.is_empty() {
60 parts.push(format!(
61 "## GitHub Users (check public activity)\n{}",
62 c.github_users.join(", ")
63 ));
64 }
65
66 if !c.github_repos.is_empty() {
67 let repos: Vec<String> = c.github_repos.iter().map(|r| format!("- {r}")).collect();
68 parts.push(format!(
69 "## GitHub Repos (highlight project work)\n{}",
70 repos.join("\n")
71 ));
72 }
73
74 if !c.instructions.is_empty() {
75 parts.push(format!("## Posting Instructions\n{}", c.instructions));
76 }
77
78 if parts.is_empty() {
79 return "No content strategy configured. Add [linkedin.content] settings to config.toml with rss_feeds, github_repos, persona, topics, and instructions.".to_string();
80 }
81
82 parts.join("\n\n")
83 }
84}
85
86#[async_trait]
87impl Tool for LinkedInTool {
88 fn name(&self) -> &str {
89 "linkedin"
90 }
91
92 fn description(&self) -> &str {
93 "Manage LinkedIn: create posts, list your posts, comment, react, delete posts, view engagement, get profile info, and read the configured content strategy. Requires LINKEDIN_* credentials in .env file."
94 }
95
96 fn parameters_schema(&self) -> serde_json::Value {
97 json!({
98 "type": "object",
99 "properties": {
100 "action": {
101 "type": "string",
102 "enum": [
103 "create_post",
104 "list_posts",
105 "comment",
106 "react",
107 "delete_post",
108 "get_engagement",
109 "get_profile",
110 "get_content_strategy"
111 ],
112 "description": "The LinkedIn action to perform"
113 },
114 "text": {
115 "type": "string",
116 "description": "Post or comment text content"
117 },
118 "visibility": {
119 "type": "string",
120 "enum": ["PUBLIC", "CONNECTIONS"],
121 "description": "Post visibility (default: PUBLIC)"
122 },
123 "article_url": {
124 "type": "string",
125 "description": "URL for link preview in a post"
126 },
127 "article_title": {
128 "type": "string",
129 "description": "Title for the article (requires article_url)"
130 },
131 "post_id": {
132 "type": "string",
133 "description": "LinkedIn post URN identifier"
134 },
135 "reaction_type": {
136 "type": "string",
137 "enum": ["LIKE", "CELEBRATE", "SUPPORT", "LOVE", "INSIGHTFUL", "FUNNY"],
138 "description": "Type of reaction to add to a post"
139 },
140 "count": {
141 "type": "integer",
142 "description": "Number of posts to retrieve (default 10, max 50)"
143 },
144 "generate_image": {
145 "type": "boolean",
146 "description": "Generate an AI image for the post (requires [linkedin.image] config). Falls back to branded SVG card if all model_providers fail."
147 },
148 "image_prompt": {
149 "type": "string",
150 "description": "Custom prompt for image generation. If omitted, a prompt is derived from the post text."
151 },
152 "scheduled_at": {
153 "type": "string",
154 "description": "Schedule the post for future publication. ISO 8601 / RFC 3339 timestamp, e.g. '2026-03-17T08:00:00Z'. The post is saved as a draft with scheduledPublishTime on LinkedIn."
155 }
156 },
157 "required": ["action"]
158 })
159 }
160
161 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
162 let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
163 ::zeroclaw_log::record!(
164 WARN,
165 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
166 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
167 .with_attrs(::serde_json::json!({"param": "action"})),
168 "linkedin: missing action parameter"
169 );
170 anyhow::Error::msg("Missing required 'action' parameter")
171 })?;
172
173 if Self::is_write_action(action) && !self.security.can_act() {
175 return Ok(ToolResult {
176 success: false,
177 output: String::new(),
178 error: Some("Action blocked: autonomy is read-only".into()),
179 });
180 }
181
182 if !self.security.record_action() {
184 return Ok(ToolResult {
185 success: false,
186 output: String::new(),
187 error: Some("Action blocked: rate limit exceeded".into()),
188 });
189 }
190
191 let client = LinkedInClient::new(self.workspace_dir.clone(), self.api_version.clone());
192
193 match action {
194 "get_content_strategy" => {
195 let strategy = self.build_content_strategy_summary();
196 return Ok(ToolResult {
197 success: true,
198 output: strategy,
199 error: None,
200 });
201 }
202 "create_post" => {
203 let text = match args.get("text").and_then(|v| v.as_str()).map(str::trim) {
204 Some(t) if !t.is_empty() => t.to_string(),
205 _ => {
206 return Ok(ToolResult {
207 success: false,
208 output: String::new(),
209 error: Some("Missing required 'text' parameter for create_post".into()),
210 });
211 }
212 };
213
214 let visibility = args
215 .get("visibility")
216 .and_then(|v| v.as_str())
217 .unwrap_or("PUBLIC");
218
219 let generate_image = args
220 .get("generate_image")
221 .and_then(|v| v.as_bool())
222 .unwrap_or(false);
223
224 let article_url = args.get("article_url").and_then(|v| v.as_str());
225 let article_title = args.get("article_title").and_then(|v| v.as_str());
226 let scheduled_at = args.get("scheduled_at").and_then(|v| v.as_str());
227
228 if article_title.is_some() && article_url.is_none() {
229 return Ok(ToolResult {
230 success: false,
231 output: String::new(),
232 error: Some("'article_title' requires 'article_url' to be provided".into()),
233 });
234 }
235
236 if generate_image && self.image_config.enabled {
238 let image_prompt = args
239 .get("image_prompt")
240 .and_then(|v| v.as_str())
241 .map(String::from)
242 .unwrap_or_else(|| {
243 format!(
244 "Professional, modern illustration for a LinkedIn post about: {}",
245 if text.len() > 200 {
246 &text[..200]
247 } else {
248 &text
249 }
250 )
251 });
252
253 let generator =
254 ImageGenerator::new(self.image_config.clone(), self.workspace_dir.clone());
255
256 match generator.generate(&image_prompt).await {
257 Ok(image_path) => {
258 let image_bytes = tokio::fs::read(&image_path).await?;
259 let creds = client.get_credentials().await?;
260 let image_urn = client
261 .upload_image(&image_bytes, &creds.access_token, &creds.person_id)
262 .await?;
263
264 let post_id = client
265 .create_post_with_image(&text, visibility, &image_urn, scheduled_at)
266 .await?;
267
268 let _ = ImageGenerator::cleanup(&image_path).await;
270
271 let action_word = if scheduled_at.is_some() {
272 "scheduled"
273 } else {
274 "published"
275 };
276 return Ok(ToolResult {
277 success: true,
278 output: format!(
279 "Post {action_word} with image. Post ID: {post_id}, Image: {image_urn}"
280 ),
281 error: None,
282 });
283 }
284 Err(e) => {
285 ::zeroclaw_log::record!(
287 WARN,
288 ::zeroclaw_log::Event::new(
289 module_path!(),
290 ::zeroclaw_log::Action::Note
291 )
292 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
293 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
294 "Image generation failed, posting without image"
295 );
296 }
297 }
298 }
299
300 let post_id = client
301 .create_post(&text, visibility, article_url, article_title, scheduled_at)
302 .await?;
303
304 let action_word = if scheduled_at.is_some() {
305 "scheduled"
306 } else {
307 "published"
308 };
309 Ok(ToolResult {
310 success: true,
311 output: format!("Post {action_word} successfully. Post ID: {post_id}"),
312 error: None,
313 })
314 }
315
316 "list_posts" => {
317 let count = args
318 .get("count")
319 .and_then(|v| v.as_u64())
320 .unwrap_or(10)
321 .clamp(1, 50) as usize;
322
323 let posts = client.list_posts(count).await?;
324
325 Ok(ToolResult {
326 success: true,
327 output: serde_json::to_string(&posts)?,
328 error: None,
329 })
330 }
331
332 "comment" => {
333 let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
334 Some(id) if !id.is_empty() => id,
335 _ => {
336 return Ok(ToolResult {
337 success: false,
338 output: String::new(),
339 error: Some("Missing required 'post_id' parameter for comment".into()),
340 });
341 }
342 };
343
344 let text = match args.get("text").and_then(|v| v.as_str()).map(str::trim) {
345 Some(t) if !t.is_empty() => t.to_string(),
346 _ => {
347 return Ok(ToolResult {
348 success: false,
349 output: String::new(),
350 error: Some("Missing required 'text' parameter for comment".into()),
351 });
352 }
353 };
354
355 let comment_id = client.add_comment(post_id, &text).await?;
356
357 Ok(ToolResult {
358 success: true,
359 output: format!("Comment posted successfully. Comment ID: {comment_id}"),
360 error: None,
361 })
362 }
363
364 "react" => {
365 let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
366 Some(id) if !id.is_empty() => id,
367 _ => {
368 return Ok(ToolResult {
369 success: false,
370 output: String::new(),
371 error: Some("Missing required 'post_id' parameter for react".into()),
372 });
373 }
374 };
375
376 let reaction_type = match args.get("reaction_type").and_then(|v| v.as_str()) {
377 Some(rt) if !rt.is_empty() => rt,
378 _ => {
379 return Ok(ToolResult {
380 success: false,
381 output: String::new(),
382 error: Some(
383 "Missing required 'reaction_type' parameter for react".into(),
384 ),
385 });
386 }
387 };
388
389 client.add_reaction(post_id, reaction_type).await?;
390
391 Ok(ToolResult {
392 success: true,
393 output: format!("Reaction '{reaction_type}' added to post {post_id}"),
394 error: None,
395 })
396 }
397
398 "delete_post" => {
399 let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
400 Some(id) if !id.is_empty() => id,
401 _ => {
402 return Ok(ToolResult {
403 success: false,
404 output: String::new(),
405 error: Some(
406 "Missing required 'post_id' parameter for delete_post".into(),
407 ),
408 });
409 }
410 };
411
412 client.delete_post(post_id).await?;
413
414 Ok(ToolResult {
415 success: true,
416 output: format!("Post {post_id} deleted successfully"),
417 error: None,
418 })
419 }
420
421 "get_engagement" => {
422 let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
423 Some(id) if !id.is_empty() => id,
424 _ => {
425 return Ok(ToolResult {
426 success: false,
427 output: String::new(),
428 error: Some(
429 "Missing required 'post_id' parameter for get_engagement".into(),
430 ),
431 });
432 }
433 };
434
435 let engagement = client.get_engagement(post_id).await?;
436
437 Ok(ToolResult {
438 success: true,
439 output: serde_json::to_string(&engagement)?,
440 error: None,
441 })
442 }
443
444 "get_profile" => {
445 let profile = client.get_profile().await?;
446
447 Ok(ToolResult {
448 success: true,
449 output: serde_json::to_string(&profile)?,
450 error: None,
451 })
452 }
453
454 unknown => Ok(ToolResult {
455 success: false,
456 output: String::new(),
457 error: Some(format!("Unknown action: '{unknown}'")),
458 }),
459 }
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use zeroclaw_config::autonomy::AutonomyLevel;
467
468 fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc<SecurityPolicy> {
469 Arc::new(SecurityPolicy {
470 autonomy: level,
471 max_actions_per_hour,
472 workspace_dir: std::env::temp_dir(),
473 ..SecurityPolicy::default()
474 })
475 }
476
477 fn make_tool(level: AutonomyLevel, max_actions: u32) -> LinkedInTool {
478 LinkedInTool::new(
479 test_security(level, max_actions),
480 PathBuf::from("/tmp"),
481 "202602".to_string(),
482 LinkedInContentConfig::default(),
483 LinkedInImageConfig::default(),
484 )
485 }
486
487 #[test]
488 fn tool_name() {
489 let tool = make_tool(AutonomyLevel::Full, 100);
490 assert_eq!(tool.name(), "linkedin");
491 }
492
493 #[test]
494 fn tool_description() {
495 let tool = make_tool(AutonomyLevel::Full, 100);
496 assert!(!tool.description().is_empty());
497 assert!(tool.description().contains("LinkedIn"));
498 }
499
500 #[test]
501 fn parameters_schema_has_required_action() {
502 let tool = make_tool(AutonomyLevel::Full, 100);
503 let schema = tool.parameters_schema();
504 assert_eq!(schema["type"], "object");
505 let required = schema["required"].as_array().unwrap();
506 assert!(required.contains(&json!("action")));
507 }
508
509 #[test]
510 fn parameters_schema_has_all_properties() {
511 let tool = make_tool(AutonomyLevel::Full, 100);
512 let schema = tool.parameters_schema();
513 let props = &schema["properties"];
514 assert!(props.get("action").is_some());
515 assert!(props.get("text").is_some());
516 assert!(props.get("visibility").is_some());
517 assert!(props.get("article_url").is_some());
518 assert!(props.get("article_title").is_some());
519 assert!(props.get("post_id").is_some());
520 assert!(props.get("reaction_type").is_some());
521 assert!(props.get("count").is_some());
522 assert!(props.get("generate_image").is_some());
523 assert!(props.get("image_prompt").is_some());
524 }
525
526 #[tokio::test]
527 async fn write_actions_blocked_in_readonly_mode() {
528 let tool = make_tool(AutonomyLevel::ReadOnly, 100);
529
530 for action in &["create_post", "comment", "react", "delete_post"] {
531 let result = tool
532 .execute(json!({
533 "action": action,
534 "text": "hello",
535 "post_id": "urn:li:share:123",
536 "reaction_type": "LIKE"
537 }))
538 .await
539 .unwrap();
540 assert!(
541 !result.success,
542 "Action '{action}' should be blocked in read-only mode"
543 );
544 assert!(
545 result.error.as_ref().unwrap().contains("read-only"),
546 "Action '{action}' error should mention read-only"
547 );
548 }
549 }
550
551 #[tokio::test]
552 async fn write_actions_blocked_by_rate_limit() {
553 let tool = make_tool(AutonomyLevel::Full, 0);
554
555 for action in &["create_post", "comment", "react", "delete_post"] {
556 let result = tool
557 .execute(json!({
558 "action": action,
559 "text": "hello",
560 "post_id": "urn:li:share:123",
561 "reaction_type": "LIKE"
562 }))
563 .await
564 .unwrap();
565 assert!(
566 !result.success,
567 "Action '{action}' should be blocked by rate limit"
568 );
569 assert!(
570 result.error.as_ref().unwrap().contains("rate limit"),
571 "Action '{action}' error should mention rate limit"
572 );
573 }
574 }
575
576 #[tokio::test]
577 async fn read_actions_not_blocked_in_readonly_mode() {
578 let tool = make_tool(AutonomyLevel::ReadOnly, 100);
582
583 for action in &["list_posts", "get_engagement", "get_profile"] {
584 let result = tool
585 .execute(json!({
586 "action": action,
587 "post_id": "urn:li:share:123"
588 }))
589 .await;
590 match result {
593 Ok(r) => {
594 if !r.success {
595 assert!(
596 !r.error.as_ref().unwrap().contains("read-only"),
597 "Read action '{action}' should not be blocked by read-only mode"
598 );
599 }
600 }
601 Err(e) => {
602 let msg = e.to_string();
604 assert!(
605 !msg.contains("read-only"),
606 "Read action '{action}' should not be blocked by read-only mode"
607 );
608 }
609 }
610 }
611 }
612
613 #[tokio::test]
614 async fn read_actions_blocked_by_rate_limit() {
615 let tool = make_tool(AutonomyLevel::ReadOnly, 0);
616
617 for action in &["list_posts", "get_engagement", "get_profile"] {
618 let result = tool
619 .execute(json!({
620 "action": action,
621 "post_id": "urn:li:share:123"
622 }))
623 .await
624 .unwrap();
625 assert!(
626 !result.success,
627 "Read action '{action}' should be rate-limited"
628 );
629 assert!(
630 result.error.as_ref().unwrap().contains("rate limit"),
631 "Read action '{action}' error should mention rate limit"
632 );
633 }
634 }
635
636 #[tokio::test]
637 async fn create_post_requires_text() {
638 let tool = make_tool(AutonomyLevel::Full, 100);
639
640 let result = tool
641 .execute(json!({"action": "create_post"}))
642 .await
643 .unwrap();
644 assert!(!result.success);
645 assert!(result.error.as_ref().unwrap().contains("text"));
646 }
647
648 #[tokio::test]
649 async fn create_post_rejects_empty_text() {
650 let tool = make_tool(AutonomyLevel::Full, 100);
651
652 let result = tool
653 .execute(json!({"action": "create_post", "text": " "}))
654 .await
655 .unwrap();
656 assert!(!result.success);
657 assert!(result.error.as_ref().unwrap().contains("text"));
658 }
659
660 #[tokio::test]
661 async fn article_title_without_url_rejected() {
662 let tool = make_tool(AutonomyLevel::Full, 100);
663
664 let result = tool
665 .execute(json!({
666 "action": "create_post",
667 "text": "Hello world",
668 "article_title": "My Article"
669 }))
670 .await
671 .unwrap();
672 assert!(!result.success);
673 assert!(result.error.as_ref().unwrap().contains("article_url"));
674 }
675
676 #[tokio::test]
677 async fn comment_requires_post_id() {
678 let tool = make_tool(AutonomyLevel::Full, 100);
679
680 let result = tool
681 .execute(json!({"action": "comment", "text": "Nice post!"}))
682 .await
683 .unwrap();
684 assert!(!result.success);
685 assert!(result.error.as_ref().unwrap().contains("post_id"));
686 }
687
688 #[tokio::test]
689 async fn comment_requires_text() {
690 let tool = make_tool(AutonomyLevel::Full, 100);
691
692 let result = tool
693 .execute(json!({"action": "comment", "post_id": "urn:li:share:123"}))
694 .await
695 .unwrap();
696 assert!(!result.success);
697 assert!(result.error.as_ref().unwrap().contains("text"));
698 }
699
700 #[tokio::test]
701 async fn react_requires_post_id() {
702 let tool = make_tool(AutonomyLevel::Full, 100);
703
704 let result = tool
705 .execute(json!({"action": "react", "reaction_type": "LIKE"}))
706 .await
707 .unwrap();
708 assert!(!result.success);
709 assert!(result.error.as_ref().unwrap().contains("post_id"));
710 }
711
712 #[tokio::test]
713 async fn react_requires_reaction_type() {
714 let tool = make_tool(AutonomyLevel::Full, 100);
715
716 let result = tool
717 .execute(json!({"action": "react", "post_id": "urn:li:share:123"}))
718 .await
719 .unwrap();
720 assert!(!result.success);
721 assert!(result.error.as_ref().unwrap().contains("reaction_type"));
722 }
723
724 #[tokio::test]
725 async fn delete_post_requires_post_id() {
726 let tool = make_tool(AutonomyLevel::Full, 100);
727
728 let result = tool
729 .execute(json!({"action": "delete_post"}))
730 .await
731 .unwrap();
732 assert!(!result.success);
733 assert!(result.error.as_ref().unwrap().contains("post_id"));
734 }
735
736 #[tokio::test]
737 async fn get_engagement_requires_post_id() {
738 let tool = make_tool(AutonomyLevel::Full, 100);
739
740 let result = tool
741 .execute(json!({"action": "get_engagement"}))
742 .await
743 .unwrap();
744 assert!(!result.success);
745 assert!(result.error.as_ref().unwrap().contains("post_id"));
746 }
747
748 #[tokio::test]
749 async fn unknown_action_returns_error() {
750 let tool = make_tool(AutonomyLevel::Full, 100);
751
752 let result = tool
753 .execute(json!({"action": "send_message"}))
754 .await
755 .unwrap();
756 assert!(!result.success);
757 assert!(result.error.as_ref().unwrap().contains("Unknown action"));
758 assert!(result.error.as_ref().unwrap().contains("send_message"));
759 }
760
761 #[tokio::test]
762 async fn get_content_strategy_returns_config() {
763 let content = LinkedInContentConfig {
764 rss_feeds: vec!["https://medium.com/feed/tag/rust".into()],
765 github_users: vec!["rareba".into()],
766 github_repos: vec!["zeroclaw-labs/zeroclaw".into()],
767 topics: vec!["cybersecurity".into(), "Rust".into()],
768 persona: "Security engineer and Rust developer".into(),
769 instructions: "Write concise posts with hashtags".into(),
770 };
771 let tool = LinkedInTool::new(
772 test_security(AutonomyLevel::Full, 100),
773 PathBuf::from("/tmp"),
774 "202602".to_string(),
775 content,
776 LinkedInImageConfig::default(),
777 );
778
779 let result = tool
780 .execute(json!({"action": "get_content_strategy"}))
781 .await
782 .unwrap();
783 assert!(result.success);
784 assert!(result.output.contains("Security engineer"));
785 assert!(result.output.contains("cybersecurity"));
786 assert!(result.output.contains("medium.com"));
787 assert!(result.output.contains("zeroclaw-labs/zeroclaw"));
788 assert!(result.output.contains("rareba"));
789 assert!(result.output.contains("Write concise posts"));
790 }
791
792 #[tokio::test]
793 async fn get_content_strategy_empty_config_shows_hint() {
794 let tool = make_tool(AutonomyLevel::Full, 100);
795
796 let result = tool
797 .execute(json!({"action": "get_content_strategy"}))
798 .await
799 .unwrap();
800 assert!(result.success);
801 assert!(result.output.contains("No content strategy configured"));
802 }
803
804 #[tokio::test]
805 async fn get_content_strategy_not_rate_limited_as_write() {
806 let tool = make_tool(AutonomyLevel::ReadOnly, 100);
808
809 let result = tool
810 .execute(json!({"action": "get_content_strategy"}))
811 .await
812 .unwrap();
813 assert!(result.success);
814 }
815
816 #[test]
817 fn parameters_schema_includes_get_content_strategy() {
818 let tool = make_tool(AutonomyLevel::Full, 100);
819 let schema = tool.parameters_schema();
820 let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
821 assert!(actions.contains(&json!("get_content_strategy")));
822 }
823}