botserver/src/channels/bluesky.rs
Rodrigo Rodriguez (Pragmatismo) b674d85583 Fix SafeCommand to allow shell scripts with redirects and command chaining
- Add shell_script_arg() method for bash/sh/cmd -c scripts
- Allow > < redirects in shell scripts (blocked in regular args)
- Allow && || command chaining in shell scripts
- Update safe_sh_command functions to use shell_script_arg
- Update run_commands, start, and LLM server commands
- Block dangerous patterns: backticks, path traversal
- Fix struct field mismatches and type errors
2026-01-08 23:50:38 -03:00

412 lines
11 KiB
Rust

use crate::channels::{
ChannelAccount, ChannelCredentials, ChannelError, ChannelProvider, ChannelType,
PostContent, PostResult,
};
use serde::{Deserialize, Serialize};
pub struct BlueskyProvider {
client: reqwest::Client,
}
impl BlueskyProvider {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
async fn create_session(
&self,
identifier: &str,
password: &str,
) -> Result<BlueskySession, ChannelError> {
let request = CreateSessionRequest {
identifier: identifier.to_string(),
password: password.to_string(),
};
let response = self
.client
.post("https://bsky.social/xrpc/com.atproto.server.createSession")
.json(&request)
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(ChannelError::AuthenticationFailed(error_text));
}
response
.json::<BlueskySession>()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})
}
async fn create_post(
&self,
session: &BlueskySession,
text: &str,
images: &[UploadedBlob],
link: Option<&str>,
) -> Result<CreateRecordResponse, ChannelError> {
let mut record = PostRecord {
text: text.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
embed: None,
facets: vec![],
};
if !images.is_empty() {
let image_embeds: Vec<ImageEmbed> = images
.iter()
.map(|blob| ImageEmbed {
alt: String::new(),
image: blob.blob.clone(),
})
.collect();
record.embed = Some(PostEmbed::Images {
embed_type: "app.bsky.embed.images".to_string(),
images: image_embeds,
});
} else if let Some(url) = link {
record.embed = Some(PostEmbed::External {
embed_type: "app.bsky.embed.external".to_string(),
external: ExternalEmbed {
uri: url.to_string(),
title: String::new(),
description: String::new(),
},
});
}
record.facets = self.extract_facets(text);
let request = CreateRecordRequest {
repo: session.did.clone(),
collection: "app.bsky.feed.post".to_string(),
record,
};
let response = self
.client
.post("https://bsky.social/xrpc/com.atproto.repo.createRecord")
.header("Authorization", format!("Bearer {}", session.access_jwt))
.json(&request)
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
if status.as_u16() == 429 {
return Err(ChannelError::RateLimited { retry_after: None });
}
return Err(ChannelError::ApiError {
code: Some(status.to_string()),
message: error_text,
});
}
response
.json::<CreateRecordResponse>()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})
}
fn extract_facets(&self, text: &str) -> Vec<Facet> {
let mut facets = Vec::new();
for (idx, word) in text.split_whitespace().enumerate() {
let byte_start = text
.split_whitespace()
.take(idx)
.map(|w| w.len() + 1)
.sum::<usize>();
let byte_end = byte_start + word.len();
if word.starts_with('@') && word.len() > 1 {
facets.push(Facet {
index: FacetIndex {
byte_start,
byte_end,
},
features: vec![FacetFeature::Mention {
feature_type: "app.bsky.richtext.facet#mention".to_string(),
did: word[1..].to_string(),
}],
});
} else if word.starts_with("https://") || word.starts_with("http://") {
facets.push(Facet {
index: FacetIndex {
byte_start,
byte_end,
},
features: vec![FacetFeature::Link {
feature_type: "app.bsky.richtext.facet#link".to_string(),
uri: word.to_string(),
}],
});
} else if word.starts_with('#') && word.len() > 1 {
facets.push(Facet {
index: FacetIndex {
byte_start,
byte_end,
},
features: vec![FacetFeature::Tag {
feature_type: "app.bsky.richtext.facet#tag".to_string(),
tag: word[1..].to_string(),
}],
});
}
}
facets
}
fn build_post_url(handle: &str, rkey: &str) -> String {
format!("https://bsky.app/profile/{}/post/{}", handle, rkey)
}
}
impl Default for BlueskyProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl ChannelProvider for BlueskyProvider {
fn channel_type(&self) -> ChannelType {
ChannelType::Bluesky
}
fn max_text_length(&self) -> usize {
300
}
fn supports_images(&self) -> bool {
true
}
fn supports_video(&self) -> bool {
false
}
fn supports_links(&self) -> bool {
true
}
async fn post(
&self,
account: &ChannelAccount,
content: &PostContent,
) -> Result<PostResult, ChannelError> {
let (identifier, password) = match &account.credentials {
ChannelCredentials::UsernamePassword {
username,
password,
app_password,
} => {
let pwd = app_password.as_ref().unwrap_or(password);
(username.clone(), pwd.clone())
}
_ => {
return Err(ChannelError::AuthenticationFailed(
"Invalid credentials type for Bluesky".to_string(),
))
}
};
let session = self.create_session(&identifier, &password).await?;
let text = content.text.as_deref().unwrap_or("");
if text.len() > self.max_text_length() {
return Err(ChannelError::ContentTooLong {
max_length: self.max_text_length(),
actual_length: text.len(),
});
}
let images: Vec<UploadedBlob> = Vec::new();
let link = content.link.as_deref();
let response = self.create_post(&session, text, &images, link).await?;
let rkey = response
.uri
.split('/')
.last()
.unwrap_or("")
.to_string();
let url = Self::build_post_url(&session.handle, &rkey);
Ok(PostResult::success(
ChannelType::Bluesky,
response.uri,
Some(url),
))
}
async fn validate_credentials(
&self,
credentials: &ChannelCredentials,
) -> Result<bool, ChannelError> {
let (identifier, password) = match credentials {
ChannelCredentials::UsernamePassword {
username,
password,
app_password,
} => {
let pwd = app_password.as_ref().unwrap_or(password);
(username.clone(), pwd.clone())
}
_ => return Ok(false),
};
match self.create_session(&identifier, &password).await {
Ok(_) => Ok(true),
Err(ChannelError::AuthenticationFailed(_)) => Ok(false),
Err(e) => Err(e),
}
}
async fn refresh_token(&self, _account: &mut ChannelAccount) -> Result<(), ChannelError> {
Ok(())
}
}
#[derive(Debug, Serialize)]
struct CreateSessionRequest {
identifier: String,
password: String,
}
#[derive(Debug, Deserialize)]
struct BlueskySession {
did: String,
handle: String,
#[serde(rename = "accessJwt")]
access_jwt: String,
}
#[derive(Debug, Serialize)]
struct CreateRecordRequest {
repo: String,
collection: String,
record: PostRecord,
}
#[derive(Debug, Serialize)]
struct PostRecord {
text: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
embed: Option<PostEmbed>,
#[serde(skip_serializing_if = "Vec::is_empty")]
facets: Vec<Facet>,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum PostEmbed {
Images {
#[serde(rename = "$type")]
embed_type: String,
images: Vec<ImageEmbed>,
},
External {
#[serde(rename = "$type")]
embed_type: String,
external: ExternalEmbed,
},
}
#[derive(Debug, Serialize)]
struct ImageEmbed {
alt: String,
image: BlobRef,
}
#[derive(Debug, Serialize)]
struct ExternalEmbed {
uri: String,
title: String,
description: String,
}
#[derive(Debug, Serialize)]
struct Facet {
index: FacetIndex,
features: Vec<FacetFeature>,
}
#[derive(Debug, Serialize)]
struct FacetIndex {
#[serde(rename = "byteStart")]
byte_start: usize,
#[serde(rename = "byteEnd")]
byte_end: usize,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum FacetFeature {
Mention {
#[serde(rename = "$type")]
feature_type: String,
did: String,
},
Link {
#[serde(rename = "$type")]
feature_type: String,
uri: String,
},
Tag {
#[serde(rename = "$type")]
feature_type: String,
tag: String,
},
}
#[derive(Debug, Deserialize)]
struct CreateRecordResponse {
uri: String,
}
#[derive(Debug, Deserialize)]
struct UploadedBlob {
blob: BlobRef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobRef {
#[serde(rename = "$type")]
blob_type: String,
#[serde(rename = "ref")]
reference: BlobLink,
#[serde(rename = "mimeType")]
mime_type: String,
size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobLink {
#[serde(rename = "$link")]
link: String,
}