Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
485 lines
17 KiB
Rust
485 lines
17 KiB
Rust
//! LinkedIn Messaging channel adapter.
|
|
//!
|
|
//! Integrates with the LinkedIn Organization Messaging API using OAuth2
|
|
//! Bearer token authentication. Polls for new messages and sends replies
|
|
//! via the REST API.
|
|
|
|
use crate::types::{
|
|
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
|
};
|
|
use async_trait::async_trait;
|
|
use chrono::Utc;
|
|
use futures::Stream;
|
|
use std::collections::HashMap;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::{mpsc, watch, RwLock};
|
|
use tracing::{info, warn};
|
|
use zeroize::Zeroizing;
|
|
|
|
const POLL_INTERVAL_SECS: u64 = 10;
|
|
const MAX_MESSAGE_LEN: usize = 3000;
|
|
const LINKEDIN_API_BASE: &str = "https://api.linkedin.com/v2";
|
|
|
|
/// LinkedIn Messaging channel adapter.
|
|
///
|
|
/// Polls the LinkedIn Organization Messaging API for new inbound messages
|
|
/// and sends replies via the same API. Requires a valid OAuth2 access token
|
|
/// with `r_organization_social` and `w_organization_social` scopes.
|
|
pub struct LinkedInAdapter {
|
|
/// SECURITY: OAuth2 access token is zeroized on drop.
|
|
access_token: Zeroizing<String>,
|
|
/// LinkedIn organization URN (e.g., "urn:li:organization:12345").
|
|
organization_id: String,
|
|
/// HTTP client.
|
|
client: reqwest::Client,
|
|
/// Shutdown signal.
|
|
shutdown_tx: Arc<watch::Sender<bool>>,
|
|
shutdown_rx: watch::Receiver<bool>,
|
|
/// Last seen message timestamp for incremental polling (epoch millis).
|
|
last_seen_ts: Arc<RwLock<i64>>,
|
|
}
|
|
|
|
impl LinkedInAdapter {
|
|
/// Create a new LinkedIn adapter.
|
|
///
|
|
/// # Arguments
|
|
/// * `access_token` - OAuth2 Bearer token with messaging permissions.
|
|
/// * `organization_id` - LinkedIn organization URN or numeric ID.
|
|
pub fn new(access_token: String, organization_id: String) -> Self {
|
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
// Normalize organization_id to URN format
|
|
let organization_id = if organization_id.starts_with("urn:") {
|
|
organization_id
|
|
} else {
|
|
format!("urn:li:organization:{}", organization_id)
|
|
};
|
|
Self {
|
|
access_token: Zeroizing::new(access_token),
|
|
organization_id,
|
|
client: reqwest::Client::new(),
|
|
shutdown_tx: Arc::new(shutdown_tx),
|
|
shutdown_rx,
|
|
last_seen_ts: Arc::new(RwLock::new(0)),
|
|
}
|
|
}
|
|
|
|
/// Build an authenticated request builder.
|
|
fn auth_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
builder
|
|
.bearer_auth(self.access_token.as_str())
|
|
.header("X-Restli-Protocol-Version", "2.0.0")
|
|
.header("LinkedIn-Version", "202401")
|
|
}
|
|
|
|
/// Validate credentials by fetching the organization info.
|
|
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
|
|
let url = format!(
|
|
"{}/organizations/{}",
|
|
LINKEDIN_API_BASE,
|
|
self.organization_id
|
|
.strip_prefix("urn:li:organization:")
|
|
.unwrap_or(&self.organization_id)
|
|
);
|
|
let resp = self.auth_request(self.client.get(&url)).send().await?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("LinkedIn auth failed (HTTP {})", resp.status()).into());
|
|
}
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let name = body["localizedName"]
|
|
.as_str()
|
|
.unwrap_or("LinkedIn Org")
|
|
.to_string();
|
|
Ok(name)
|
|
}
|
|
|
|
/// Fetch new messages from the organization messaging inbox.
|
|
async fn fetch_messages(
|
|
client: &reqwest::Client,
|
|
access_token: &str,
|
|
organization_id: &str,
|
|
after_ts: i64,
|
|
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
|
|
let url = format!(
|
|
"{}/organizationMessages?q=organization&organization={}&count=50",
|
|
LINKEDIN_API_BASE,
|
|
url::form_urlencoded::Serializer::new(String::new())
|
|
.append_pair("org", organization_id)
|
|
.finish()
|
|
.split('=')
|
|
.nth(1)
|
|
.unwrap_or(organization_id)
|
|
);
|
|
|
|
let resp = client
|
|
.get(&url)
|
|
.bearer_auth(access_token)
|
|
.header("X-Restli-Protocol-Version", "2.0.0")
|
|
.header("LinkedIn-Version", "202401")
|
|
.send()
|
|
.await?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("LinkedIn: HTTP {}", resp.status()).into());
|
|
}
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let elements = body["elements"].as_array().cloned().unwrap_or_default();
|
|
|
|
// Filter to messages after the given timestamp
|
|
let filtered: Vec<serde_json::Value> = elements
|
|
.into_iter()
|
|
.filter(|msg| {
|
|
let created = msg["createdAt"].as_i64().unwrap_or(0);
|
|
created > after_ts
|
|
})
|
|
.collect();
|
|
|
|
Ok(filtered)
|
|
}
|
|
|
|
/// Send a message via the LinkedIn Organization Messaging API.
|
|
async fn api_send_message(
|
|
&self,
|
|
recipient_urn: &str,
|
|
text: &str,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let url = format!("{}/organizationMessages", LINKEDIN_API_BASE);
|
|
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
|
let num_chunks = chunks.len();
|
|
|
|
for chunk in chunks {
|
|
let body = serde_json::json!({
|
|
"recipients": [recipient_urn],
|
|
"organization": self.organization_id,
|
|
"body": {
|
|
"text": chunk,
|
|
},
|
|
"messageType": "MEMBER_TO_MEMBER",
|
|
});
|
|
|
|
let resp = self
|
|
.auth_request(self.client.post(&url))
|
|
.json(&body)
|
|
.send()
|
|
.await?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let err_body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("LinkedIn API error {status}: {err_body}").into());
|
|
}
|
|
|
|
// LinkedIn rate limit: max 100 requests per day for messaging
|
|
// Small delay between chunks to be respectful
|
|
if num_chunks > 1 {
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Parse a LinkedIn message element into usable fields.
|
|
fn parse_message_element(
|
|
element: &serde_json::Value,
|
|
) -> Option<(String, String, String, String, i64)> {
|
|
let id = element["id"].as_str()?.to_string();
|
|
let body_text = element["body"]["text"].as_str()?.to_string();
|
|
if body_text.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let sender_urn = element["from"].as_str().unwrap_or("unknown").to_string();
|
|
let sender_name = element["fromName"]
|
|
.as_str()
|
|
.or_else(|| element["senderName"].as_str())
|
|
.unwrap_or("LinkedIn User")
|
|
.to_string();
|
|
let created_at = element["createdAt"].as_i64().unwrap_or(0);
|
|
|
|
Some((id, body_text, sender_urn, sender_name, created_at))
|
|
}
|
|
|
|
/// Get the numeric organization ID.
|
|
pub fn org_numeric_id(&self) -> &str {
|
|
self.organization_id
|
|
.strip_prefix("urn:li:organization:")
|
|
.unwrap_or(&self.organization_id)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ChannelAdapter for LinkedInAdapter {
|
|
fn name(&self) -> &str {
|
|
"linkedin"
|
|
}
|
|
|
|
fn channel_type(&self) -> ChannelType {
|
|
ChannelType::Custom("linkedin".to_string())
|
|
}
|
|
|
|
async fn start(
|
|
&self,
|
|
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
|
{
|
|
let org_name = self.validate().await?;
|
|
info!("LinkedIn adapter authenticated for org: {org_name}");
|
|
|
|
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
|
let access_token = self.access_token.clone();
|
|
let organization_id = self.organization_id.clone();
|
|
let client = self.client.clone();
|
|
let last_seen_ts = Arc::clone(&self.last_seen_ts);
|
|
let mut shutdown_rx = self.shutdown_rx.clone();
|
|
|
|
// Initialize last_seen_ts to now so we only get new messages
|
|
{
|
|
*last_seen_ts.write().await = Utc::now().timestamp_millis();
|
|
}
|
|
|
|
let poll_interval = Duration::from_secs(POLL_INTERVAL_SECS);
|
|
|
|
tokio::spawn(async move {
|
|
let mut backoff = Duration::from_secs(1);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = shutdown_rx.changed() => {
|
|
if *shutdown_rx.borrow() {
|
|
info!("LinkedIn adapter shutting down");
|
|
break;
|
|
}
|
|
}
|
|
_ = tokio::time::sleep(poll_interval) => {}
|
|
}
|
|
|
|
if *shutdown_rx.borrow() {
|
|
break;
|
|
}
|
|
|
|
let after_ts = *last_seen_ts.read().await;
|
|
|
|
let poll_result =
|
|
Self::fetch_messages(&client, &access_token, &organization_id, after_ts)
|
|
.await
|
|
.map_err(|e| e.to_string());
|
|
|
|
let messages = match poll_result {
|
|
Ok(m) => {
|
|
backoff = Duration::from_secs(1);
|
|
m
|
|
}
|
|
Err(msg) => {
|
|
warn!("LinkedIn: poll error: {msg}, backing off {backoff:?}");
|
|
tokio::time::sleep(backoff).await;
|
|
backoff = (backoff * 2).min(Duration::from_secs(300));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let mut max_ts = after_ts;
|
|
|
|
for element in &messages {
|
|
let (id, body_text, sender_urn, sender_name, created_at) =
|
|
match Self::parse_message_element(element) {
|
|
Some(parsed) => parsed,
|
|
None => continue,
|
|
};
|
|
|
|
// Skip messages from own organization
|
|
if sender_urn.contains(&organization_id) {
|
|
continue;
|
|
}
|
|
|
|
if created_at > max_ts {
|
|
max_ts = created_at;
|
|
}
|
|
|
|
let thread_id = element["conversationId"]
|
|
.as_str()
|
|
.or_else(|| element["threadId"].as_str())
|
|
.map(String::from);
|
|
|
|
let content = if body_text.starts_with('/') {
|
|
let parts: Vec<&str> = body_text.splitn(2, ' ').collect();
|
|
let cmd = parts[0].trim_start_matches('/');
|
|
let args: Vec<String> = parts
|
|
.get(1)
|
|
.map(|a| a.split_whitespace().map(String::from).collect())
|
|
.unwrap_or_default();
|
|
ChannelContent::Command {
|
|
name: cmd.to_string(),
|
|
args,
|
|
}
|
|
} else {
|
|
ChannelContent::Text(body_text)
|
|
};
|
|
|
|
let msg = ChannelMessage {
|
|
channel: ChannelType::Custom("linkedin".to_string()),
|
|
platform_message_id: id,
|
|
sender: ChannelUser {
|
|
platform_id: sender_urn.clone(),
|
|
display_name: sender_name,
|
|
openfang_user: None,
|
|
},
|
|
content,
|
|
target_agent: None,
|
|
timestamp: Utc::now(),
|
|
is_group: false,
|
|
thread_id,
|
|
metadata: {
|
|
let mut m = HashMap::new();
|
|
m.insert(
|
|
"sender_urn".to_string(),
|
|
serde_json::Value::String(sender_urn),
|
|
);
|
|
m.insert(
|
|
"organization_id".to_string(),
|
|
serde_json::Value::String(organization_id.clone()),
|
|
);
|
|
m
|
|
},
|
|
};
|
|
|
|
if tx.send(msg).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if max_ts > after_ts {
|
|
*last_seen_ts.write().await = max_ts;
|
|
}
|
|
}
|
|
|
|
info!("LinkedIn polling loop stopped");
|
|
});
|
|
|
|
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn send(
|
|
&self,
|
|
user: &ChannelUser,
|
|
content: ChannelContent,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let text = match content {
|
|
ChannelContent::Text(t) => t,
|
|
_ => "(Unsupported content type)".to_string(),
|
|
};
|
|
|
|
// user.platform_id should be the recipient's LinkedIn URN
|
|
self.api_send_message(&user.platform_id, &text).await
|
|
}
|
|
|
|
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
|
// LinkedIn Messaging API does not support typing indicators.
|
|
Ok(())
|
|
}
|
|
|
|
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
|
let _ = self.shutdown_tx.send(true);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_linkedin_adapter_creation() {
|
|
let adapter = LinkedInAdapter::new("test-token".to_string(), "12345".to_string());
|
|
assert_eq!(adapter.name(), "linkedin");
|
|
assert_eq!(
|
|
adapter.channel_type(),
|
|
ChannelType::Custom("linkedin".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_linkedin_organization_id_normalization() {
|
|
let adapter = LinkedInAdapter::new("tok".to_string(), "12345".to_string());
|
|
assert_eq!(adapter.organization_id, "urn:li:organization:12345");
|
|
|
|
let adapter2 =
|
|
LinkedInAdapter::new("tok".to_string(), "urn:li:organization:67890".to_string());
|
|
assert_eq!(adapter2.organization_id, "urn:li:organization:67890");
|
|
}
|
|
|
|
#[test]
|
|
fn test_linkedin_org_numeric_id() {
|
|
let adapter = LinkedInAdapter::new("tok".to_string(), "12345".to_string());
|
|
assert_eq!(adapter.org_numeric_id(), "12345");
|
|
}
|
|
|
|
#[test]
|
|
fn test_linkedin_auth_headers() {
|
|
let adapter = LinkedInAdapter::new("my-oauth-token".to_string(), "12345".to_string());
|
|
let builder = adapter.client.get("https://api.linkedin.com/v2/me");
|
|
let builder = adapter.auth_request(builder);
|
|
let request = builder.build().unwrap();
|
|
assert!(request.headers().contains_key("authorization"));
|
|
assert_eq!(
|
|
request.headers().get("X-Restli-Protocol-Version").unwrap(),
|
|
"2.0.0"
|
|
);
|
|
assert_eq!(request.headers().get("LinkedIn-Version").unwrap(), "202401");
|
|
}
|
|
|
|
#[test]
|
|
fn test_linkedin_parse_message_element() {
|
|
let element = serde_json::json!({
|
|
"id": "msg-001",
|
|
"body": { "text": "Hello from LinkedIn" },
|
|
"from": "urn:li:person:abc123",
|
|
"fromName": "Jane Doe",
|
|
"createdAt": 1700000000000_i64,
|
|
});
|
|
let result = LinkedInAdapter::parse_message_element(&element);
|
|
assert!(result.is_some());
|
|
let (id, body, from, name, ts) = result.unwrap();
|
|
assert_eq!(id, "msg-001");
|
|
assert_eq!(body, "Hello from LinkedIn");
|
|
assert_eq!(from, "urn:li:person:abc123");
|
|
assert_eq!(name, "Jane Doe");
|
|
assert_eq!(ts, 1700000000000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_linkedin_parse_message_empty_body() {
|
|
let element = serde_json::json!({
|
|
"id": "msg-002",
|
|
"body": { "text": "" },
|
|
"from": "urn:li:person:xyz",
|
|
});
|
|
assert!(LinkedInAdapter::parse_message_element(&element).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_linkedin_parse_message_missing_body() {
|
|
let element = serde_json::json!({
|
|
"id": "msg-003",
|
|
"from": "urn:li:person:xyz",
|
|
});
|
|
assert!(LinkedInAdapter::parse_message_element(&element).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_linkedin_parse_message_defaults() {
|
|
let element = serde_json::json!({
|
|
"id": "msg-004",
|
|
"body": { "text": "Hi" },
|
|
});
|
|
let result = LinkedInAdapter::parse_message_element(&element);
|
|
assert!(result.is_some());
|
|
let (_, _, from, name, _) = result.unwrap();
|
|
assert_eq!(from, "unknown");
|
|
assert_eq!(name, "LinkedIn User");
|
|
}
|
|
}
|