Skip to main content

ta_submit/
social_plugin_protocol.rs

1//! JSON-over-stdio protocol types for external social media adapter plugins.
2//!
3//! Social adapter plugins communicate with TA using a request/response
4//! protocol over stdin/stdout. TA spawns the plugin process, writes one JSON
5//! request line to stdin, reads one JSON response line from stdout.
6//!
7//! ## Protocol overview
8//!
9//! Every exchange is a single JSON line in each direction:
10//!
11//! ```text
12//! TA → plugin: {"op":"<name>",...params...}
13//! plugin → TA: {"ok":true,...result...}   or   {"ok":false,"error":"..."}
14//! ```
15//!
16//! ## Operations
17//!
18//! | Op                 | Description                                              |
19//! |--------------------|----------------------------------------------------------|
20//! | `create_draft`     | Write a draft to the platform's native draft state       |
21//! | `create_scheduled` | Schedule a post at a future time (platform scheduler)    |
22//! | `draft_status`     | Poll whether a draft was published, deleted, or open     |
23//! | `health`           | Connectivity + credential check                          |
24//! | `capabilities`     | Advertise which optional ops this plugin supports        |
25//!
26//! ## Safety invariant — `publish` is absent by design
27//!
28//! The `publish` operation is intentionally absent from this protocol.
29//! TA never publishes social media posts on behalf of the user. Plugins
30//! expose only `create_draft` and `create_scheduled`; the user publishes
31//! from their native platform UI or scheduler (e.g., LinkedIn, Buffer).
32//! This is a deliberate safety boundary enforced at the type level.
33
34use serde::{Deserialize, Serialize};
35
36/// Protocol version implemented by this TA build.
37pub const SOCIAL_PROTOCOL_VERSION: u32 = 1;
38
39// ---------------------------------------------------------------------------
40// Request envelope
41// ---------------------------------------------------------------------------
42
43/// Request sent from TA to a social media plugin over stdin.
44///
45/// One JSON line per request. The plugin processes it and writes one
46/// `SocialPluginResponse` line to stdout, then the process exits.
47///
48/// The `op` field selects the operation. Additional fields carry
49/// operation-specific parameters (flat layout, not nested).
50#[derive(Debug, Serialize, Deserialize, PartialEq)]
51#[serde(tag = "op", rename_all = "snake_case")]
52pub enum SocialPluginRequest {
53    /// Create a draft in the platform's native draft state.
54    ///
55    /// NOTE: There is intentionally no `Publish` variant. TA never publishes.
56    CreateDraft(CreateSocialDraftParams),
57
58    /// Schedule a post at a future time via the platform's native scheduler.
59    ///
60    /// NOTE: This schedules a post but does not publish it immediately.
61    /// The platform (or its scheduler, e.g., Buffer) controls the actual send.
62    CreateScheduled(CreateScheduledParams),
63
64    /// Poll the current state of a previously created draft or scheduled post.
65    DraftStatus(SocialDraftStatusParams),
66
67    /// Connectivity and credential health check.
68    Health(SocialHealthParams),
69
70    /// Advertise optional capabilities supported by this plugin.
71    Capabilities(SocialCapabilitiesParams),
72}
73
74// ---------------------------------------------------------------------------
75// Response envelope
76// ---------------------------------------------------------------------------
77
78/// Response sent from a social media plugin to TA over stdout.
79///
80/// One JSON line per response. Always contains `ok`; on success contains
81/// the operation result fields; on failure contains `error`.
82#[derive(Debug, Serialize, Deserialize)]
83pub struct SocialPluginResponse {
84    /// Whether the operation succeeded.
85    pub ok: bool,
86
87    /// Human-readable error message (only set when ok=false).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub error: Option<String>,
90
91    /// Native draft ID assigned by the platform (only for create_draft op).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub draft_id: Option<String>,
94
95    /// Native scheduled post ID (only for create_scheduled op).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub scheduled_id: Option<String>,
98
99    /// ISO-8601 timestamp when the post is scheduled to go out (create_scheduled op).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub scheduled_at: Option<String>,
102
103    /// Current state of a draft or scheduled post (only for draft_status op).
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub state: Option<SocialPostState>,
106
107    /// Connected handle / username (only for health op, e.g., "@username").
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub handle: Option<String>,
110
111    /// Provider name reported by the plugin (only for health op).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub provider: Option<String>,
114
115    /// Capabilities declared by the plugin (only for capabilities op).
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub capabilities: Option<Vec<String>>,
118}
119
120impl SocialPluginResponse {
121    /// Construct a success response with no result fields.
122    pub fn ok() -> Self {
123        Self {
124            ok: true,
125            error: None,
126            draft_id: None,
127            scheduled_id: None,
128            scheduled_at: None,
129            state: None,
130            handle: None,
131            provider: None,
132            capabilities: None,
133        }
134    }
135
136    /// Construct an error response.
137    pub fn error(msg: impl Into<String>) -> Self {
138        Self {
139            ok: false,
140            error: Some(msg.into()),
141            draft_id: None,
142            scheduled_id: None,
143            scheduled_at: None,
144            state: None,
145            handle: None,
146            provider: None,
147            capabilities: None,
148        }
149    }
150}
151
152// ---------------------------------------------------------------------------
153// create_draft
154// ---------------------------------------------------------------------------
155
156/// Parameters for the `create_draft` operation.
157///
158/// The plugin writes this draft to the platform's native draft state.
159/// For LinkedIn: Draft Share API. For X: draft tweet endpoint.
160/// For Buffer: Buffer Draft queue.
161///
162/// The user sees the draft in their platform UI and publishes when ready.
163/// TA never publishes directly.
164#[derive(Debug, Serialize, Deserialize, PartialEq)]
165pub struct CreateSocialDraftParams {
166    /// Post content to draft.
167    pub post: SocialPostContent,
168}
169
170/// The content of a social media post to be drafted or scheduled.
171#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
172pub struct SocialPostContent {
173    /// Main post body text.
174    pub body: String,
175
176    /// URLs to media attachments (images, videos). May be empty.
177    #[serde(default)]
178    pub media_urls: Vec<String>,
179
180    /// Post ID or URL being replied to (for threaded replies). None for new posts.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub reply_to_id: Option<String>,
183}
184
185// ---------------------------------------------------------------------------
186// create_scheduled
187// ---------------------------------------------------------------------------
188
189/// Parameters for the `create_scheduled` operation.
190///
191/// The plugin queues this post in the platform's native scheduler.
192/// The post goes live at `scheduled_at` without further TA involvement.
193#[derive(Debug, Serialize, Deserialize, PartialEq)]
194pub struct CreateScheduledParams {
195    /// Post content to schedule.
196    pub post: SocialPostContent,
197
198    /// ISO-8601 timestamp when the post should go live.
199    pub scheduled_at: String,
200}
201
202// ---------------------------------------------------------------------------
203// draft_status
204// ---------------------------------------------------------------------------
205
206/// Parameters for the `draft_status` operation.
207#[derive(Debug, Serialize, Deserialize, PartialEq)]
208pub struct SocialDraftStatusParams {
209    /// Platform-specific draft ID returned by `create_draft` or `create_scheduled`.
210    pub draft_id: String,
211}
212
213/// Current state of a social post as reported by the platform.
214#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
215#[serde(rename_all = "snake_case")]
216pub enum SocialPostState {
217    /// Draft exists and has not been published.
218    Draft,
219    /// The post has been published (by the user or scheduler).
220    Published,
221    /// The draft or scheduled post was deleted.
222    Deleted,
223    /// State cannot be determined (e.g., platform API limitations).
224    Unknown,
225}
226
227impl std::fmt::Display for SocialPostState {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            SocialPostState::Draft => write!(f, "draft"),
231            SocialPostState::Published => write!(f, "published"),
232            SocialPostState::Deleted => write!(f, "deleted"),
233            SocialPostState::Unknown => write!(f, "unknown"),
234        }
235    }
236}
237
238// ---------------------------------------------------------------------------
239// health
240// ---------------------------------------------------------------------------
241
242/// Parameters for the `health` operation.
243///
244/// No parameters required — plugins use their configured credentials.
245#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
246pub struct SocialHealthParams {}
247
248// ---------------------------------------------------------------------------
249// capabilities
250// ---------------------------------------------------------------------------
251
252/// Parameters for the `capabilities` operation.
253///
254/// No parameters required.
255#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
256pub struct SocialCapabilitiesParams {}
257
258// ---------------------------------------------------------------------------
259// Error type
260// ---------------------------------------------------------------------------
261
262/// Errors from social media plugin operations.
263#[derive(Debug, thiserror::Error)]
264pub enum SocialPluginError {
265    #[error("social plugin not found: {name}. Install with: ta adapter setup social/{name}")]
266    PluginNotFound { name: String },
267
268    #[error("social plugin '{name}' op '{op}' failed: {reason}")]
269    OpFailed {
270        name: String,
271        op: String,
272        reason: String,
273    },
274
275    #[error("social plugin '{name}' produced invalid response for op '{op}': {reason}")]
276    InvalidResponse {
277        name: String,
278        op: String,
279        reason: String,
280    },
281
282    #[error("failed to spawn social plugin '{command}': {reason}. Ensure the plugin is on PATH.")]
283    SpawnFailed { command: String, reason: String },
284
285    #[error("social plugin '{name}' timed out after {timeout_secs}s for op '{op}'. Increase timeout in plugin.toml.")]
286    Timeout {
287        name: String,
288        op: String,
289        timeout_secs: u64,
290    },
291
292    #[error("I/O error: {0}")]
293    Io(#[from] std::io::Error),
294
295    #[error("JSON error: {0}")]
296    Json(#[from] serde_json::Error),
297}
298
299// ---------------------------------------------------------------------------
300// Tests
301// ---------------------------------------------------------------------------
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn create_draft_request_roundtrip() {
309        let req = SocialPluginRequest::CreateDraft(CreateSocialDraftParams {
310            post: SocialPostContent {
311                body: "Excited to announce the cinepipe launch! 🎬".to_string(),
312                media_urls: vec![],
313                reply_to_id: None,
314            },
315        });
316        let json = serde_json::to_string(&req).unwrap();
317        assert!(json.contains("\"op\":\"create_draft\""));
318        let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
319        assert_eq!(parsed, req);
320    }
321
322    #[test]
323    fn create_scheduled_request_roundtrip() {
324        let req = SocialPluginRequest::CreateScheduled(CreateScheduledParams {
325            post: SocialPostContent {
326                body: "Week 1 of our public alpha is live!".to_string(),
327                media_urls: vec!["https://example.com/screenshot.png".to_string()],
328                reply_to_id: None,
329            },
330            scheduled_at: "2026-04-07T14:00:00Z".to_string(),
331        });
332        let json = serde_json::to_string(&req).unwrap();
333        assert!(json.contains("\"op\":\"create_scheduled\""));
334        assert!(json.contains("2026-04-07T14:00:00Z"));
335        let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
336        assert_eq!(parsed, req);
337    }
338
339    #[test]
340    fn no_publish_op_variant() {
341        // The protocol MUST NOT have a Publish variant.
342        let req = SocialPluginRequest::Health(SocialHealthParams {});
343        let json = serde_json::to_string(&req).unwrap();
344        assert!(
345            !json.contains("\"publish\""),
346            "Publish op must not exist in the social protocol"
347        );
348    }
349
350    #[test]
351    fn draft_status_request_roundtrip() {
352        let req = SocialPluginRequest::DraftStatus(SocialDraftStatusParams {
353            draft_id: "linkedin-draft-xyz".to_string(),
354        });
355        let json = serde_json::to_string(&req).unwrap();
356        assert!(json.contains("\"op\":\"draft_status\""));
357        let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
358        assert_eq!(parsed, req);
359    }
360
361    #[test]
362    fn health_request_roundtrip() {
363        let req = SocialPluginRequest::Health(SocialHealthParams {});
364        let json = serde_json::to_string(&req).unwrap();
365        assert!(json.contains("\"op\":\"health\""));
366        let parsed: SocialPluginRequest = serde_json::from_str(&json).unwrap();
367        assert_eq!(parsed, req);
368    }
369
370    #[test]
371    fn response_ok_roundtrip() {
372        let resp = SocialPluginResponse::ok();
373        let json = serde_json::to_string(&resp).unwrap();
374        let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
375        assert!(parsed.ok);
376        assert!(parsed.error.is_none());
377    }
378
379    #[test]
380    fn response_error_roundtrip() {
381        let resp = SocialPluginResponse::error("credentials not found");
382        let json = serde_json::to_string(&resp).unwrap();
383        let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
384        assert!(!parsed.ok);
385        assert_eq!(parsed.error.as_deref(), Some("credentials not found"));
386    }
387
388    #[test]
389    fn response_with_draft_id() {
390        let mut resp = SocialPluginResponse::ok();
391        resp.draft_id = Some("linkedin-draft-abc123".to_string());
392        let json = serde_json::to_string(&resp).unwrap();
393        let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
394        assert_eq!(parsed.draft_id.as_deref(), Some("linkedin-draft-abc123"));
395    }
396
397    #[test]
398    fn response_with_scheduled_id() {
399        let mut resp = SocialPluginResponse::ok();
400        resp.scheduled_id = Some("buffer-post-xyz".to_string());
401        resp.scheduled_at = Some("2026-04-07T14:00:00Z".to_string());
402        let json = serde_json::to_string(&resp).unwrap();
403        let parsed: SocialPluginResponse = serde_json::from_str(&json).unwrap();
404        assert_eq!(parsed.scheduled_id.as_deref(), Some("buffer-post-xyz"));
405        assert_eq!(parsed.scheduled_at.as_deref(), Some("2026-04-07T14:00:00Z"));
406    }
407
408    #[test]
409    fn post_state_display() {
410        assert_eq!(SocialPostState::Draft.to_string(), "draft");
411        assert_eq!(SocialPostState::Published.to_string(), "published");
412        assert_eq!(SocialPostState::Deleted.to_string(), "deleted");
413        assert_eq!(SocialPostState::Unknown.to_string(), "unknown");
414    }
415
416    #[test]
417    fn post_state_roundtrip() {
418        for state in [
419            SocialPostState::Draft,
420            SocialPostState::Published,
421            SocialPostState::Deleted,
422            SocialPostState::Unknown,
423        ] {
424            let json = serde_json::to_string(&state).unwrap();
425            let parsed: SocialPostState = serde_json::from_str(&json).unwrap();
426            assert_eq!(parsed, state);
427        }
428    }
429
430    #[test]
431    fn social_protocol_version_is_one() {
432        assert_eq!(SOCIAL_PROTOCOL_VERSION, 1);
433    }
434
435    #[test]
436    fn post_content_with_media_urls() {
437        let post = SocialPostContent {
438            body: "Check out our new feature!".to_string(),
439            media_urls: vec![
440                "https://example.com/img1.png".to_string(),
441                "https://example.com/img2.png".to_string(),
442            ],
443            reply_to_id: None,
444        };
445        let json = serde_json::to_string(&post).unwrap();
446        let parsed: SocialPostContent = serde_json::from_str(&json).unwrap();
447        assert_eq!(parsed.media_urls.len(), 2);
448    }
449
450    #[test]
451    fn post_content_reply_to_id() {
452        let post = SocialPostContent {
453            body: "Replying to this!".to_string(),
454            media_urls: vec![],
455            reply_to_id: Some("tweet-12345".to_string()),
456        };
457        let json = serde_json::to_string(&post).unwrap();
458        assert!(json.contains("reply_to_id"));
459        let parsed: SocialPostContent = serde_json::from_str(&json).unwrap();
460        assert_eq!(parsed.reply_to_id.as_deref(), Some("tweet-12345"));
461    }
462}