mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 01:18:34 +00:00
250 lines
7.1 KiB
Rust
250 lines
7.1 KiB
Rust
//! A Twitter bot for Vo.codes
|
|
//! Copyright 2020 Brandon Thomas
|
|
|
|
mod common;
|
|
mod movie_maker;
|
|
mod vocodes_api;
|
|
mod workers;
|
|
|
|
use anyhow::bail;
|
|
use crate::workers::{spawn_workers, Workload};
|
|
use crossbeam::Receiver;
|
|
use easyenv::{init_env_logger, get_env_num, get_env_string};
|
|
use egg_mode::error::Error;
|
|
use egg_mode::media::{media_types, upload_media, set_metadata, get_status, ProgressInfo, MediaHandle};
|
|
use egg_mode::stream::StreamMessage;
|
|
use egg_mode::tweet::DraftTweet;
|
|
use futures::TryStreamExt;
|
|
use log::{warn, info, error};
|
|
use std::io::{stdout, Write};
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use std::{env, thread};
|
|
use tokio::runtime::Builder;
|
|
use tokio::time::delay_for;
|
|
|
|
pub type AnyhowResult<T> = anyhow::Result<T>;
|
|
|
|
const ENV_ASSET_IMAGE_PATH : &'static str = "ASSET_IMAGE_PATH";
|
|
const ENV_QUEUE_LENGTH : &'static str = "QUEUE_LENGTH";
|
|
const ENV_TEMP_DIRECTORY : &'static str = "TEMP_DIRECTORY";
|
|
const ENV_THREAD_COUNT : &'static str = "THREAD_COUNT";
|
|
const ENV_TWEET_TEXT : &'static str = "TWEET_TEXT";
|
|
const ENV_WORKER_THREAD_COUNT : &'static str = "WORKER_THREAD_COUNT";
|
|
|
|
const VOCODES_BOT_USER_ID : u64 = 1310037441441599488;
|
|
|
|
#[derive(Clone)]
|
|
pub struct Secrets {
|
|
pub api_key: String,
|
|
pub api_secret_key: String,
|
|
pub access_token: String,
|
|
pub access_token_secret: String,
|
|
pub username: String,
|
|
// Not sure where to get the user id, or what it's used for.
|
|
pub user_id: u64,
|
|
}
|
|
|
|
impl Secrets {
|
|
pub fn load_from_env() -> AnyhowResult<Self> {
|
|
Ok(Secrets {
|
|
api_key: env::var("API_KEY")?,
|
|
api_secret_key: env::var("API_SECRET_KEY")?,
|
|
access_token: env::var("ACCESS_TOKEN")?,
|
|
access_token_secret: env::var("ACCESS_TOKEN_SECRET")?,
|
|
username: env::var("USERNAME")?,
|
|
user_id: get_env_num::<u64>("USER_ID", 0)?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ProgramArgs {
|
|
pub tweet_text: String,
|
|
pub png_filename: String,
|
|
pub temp_directory: String,
|
|
pub core_thread_count: usize,
|
|
pub queue_length: usize,
|
|
pub worker_count: usize,
|
|
}
|
|
|
|
//#[tokio::main]
|
|
fn main() -> AnyhowResult<()> {
|
|
init_env_logger(None);
|
|
|
|
let program_args = ProgramArgs {
|
|
tweet_text: get_env_string(ENV_TWEET_TEXT, "This is a deepfake response generated by vo.codes. Use responsibly."),
|
|
temp_directory: get_env_string(ENV_TEMP_DIRECTORY, "/tmp"),
|
|
png_filename: get_env_string(ENV_ASSET_IMAGE_PATH, "./assets/sonic-full.png"),
|
|
core_thread_count: get_env_num(ENV_THREAD_COUNT, 10)?,
|
|
queue_length: get_env_num(ENV_QUEUE_LENGTH, 10)?,
|
|
worker_count: get_env_num(ENV_WORKER_THREAD_COUNT, 5)?,
|
|
};
|
|
|
|
let mut runtime = tokio::runtime::Builder::new()
|
|
.threaded_scheduler()
|
|
.enable_all()
|
|
.core_threads(program_args.core_thread_count)
|
|
.on_thread_start(|| {
|
|
println!("thread started");
|
|
})
|
|
.build()
|
|
.unwrap();
|
|
|
|
runtime.block_on(async {
|
|
start_bot(program_args).await
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_bot(program_args: ProgramArgs) -> AnyhowResult<()> {
|
|
info!("Loading configs, secrets, and logging in...");
|
|
|
|
let secrets = Secrets::load_from_env()?;
|
|
let config = common::Config::load(secrets.clone()).await;
|
|
|
|
info!("Configs loaded. Spawning workers...");
|
|
|
|
let (sender, receiver) = crossbeam::bounded(program_args.queue_length);
|
|
spawn_workers(config.clone(), receiver, program_args).await;
|
|
|
|
info!("Streaming tweets...");
|
|
|
|
let stream = egg_mode::stream::filter()
|
|
.track(&["vocodesbot"])
|
|
.language(&["en"])
|
|
.start(&config.token)
|
|
.try_for_each(|m| {
|
|
if let StreamMessage::Tweet(tweet) = m {
|
|
let user_id = tweet.user
|
|
.as_ref()
|
|
.map(|u| u.id)
|
|
.unwrap_or(0);
|
|
|
|
let user_name = tweet.user
|
|
.as_ref()
|
|
.map(|u| u.screen_name.clone())
|
|
.unwrap_or("unnamed".to_string());
|
|
|
|
if user_id == VOCODES_BOT_USER_ID {
|
|
info!("Tweet originated from VocodesBot ({}); skipping.", &user_name);
|
|
return futures::future::ok(());
|
|
}
|
|
|
|
if tweet.text.contains("deepfake response") {
|
|
info!("Tweet contains \"deepfake response\"; skipping. Username: {}", &user_name);
|
|
return futures::future::ok(());
|
|
}
|
|
|
|
if tweet.in_reply_to_user_id.is_some() {
|
|
info!("Tweet has 'in_reply_to_user_id'; skipping. Username: {}", &user_name);
|
|
return futures::future::ok(());
|
|
}
|
|
|
|
common::print_tweet(&tweet);
|
|
|
|
sender.send(Workload { tweet: tweet.clone() });
|
|
|
|
} else {
|
|
info!("Not a message: {:?}", m);
|
|
}
|
|
|
|
futures::future::ok(())
|
|
});
|
|
|
|
if let Err(e) = stream.await {
|
|
let e : egg_mode::error::Error = e;
|
|
error!("Stream error: {}", &e);
|
|
match e {
|
|
Error::BadUrl => {},
|
|
Error::InvalidResponse(_, _) => {},
|
|
Error::MissingValue(_) => {},
|
|
Error::FutureAlreadyCompleted => {},
|
|
Error::TwitterError(_, _) => {},
|
|
Error::RateLimit(rate_limit) => {
|
|
error!("Rate limited. Will reopen at unix timestamp: {}", rate_limit);
|
|
},
|
|
Error::MediaError(_) => {},
|
|
Error::BadStatus(_) => {},
|
|
Error::NetError(_) => {},
|
|
Error::TlsError(_) => {},
|
|
Error::IOError(_) => {},
|
|
Error::DeserializeError(_) => {},
|
|
Error::TimestampParseError(_) => {},
|
|
Error::TimerShutdownError(_) => {},
|
|
Error::HeaderParseError(_) => {},
|
|
Error::HeaderConvertError(_) => {},
|
|
}
|
|
error!("Disconnected")
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn respond_with_media(
|
|
config: &common::Config,
|
|
tweet_text: &str,
|
|
media_path: PathBuf,
|
|
media_alt_text: &str) -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let mut tweet = DraftTweet::new(tweet_text.to_string());
|
|
|
|
let handle = upload_media_to_twitter(config, media_path, media_alt_text).await?;
|
|
tweet.add_media(handle.id.clone());
|
|
|
|
tweet.send(&config.token).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn upload_media_to_twitter(
|
|
config: &common::Config,
|
|
media_path: PathBuf,
|
|
media_alt_text: &str) -> AnyhowResult<MediaHandle>
|
|
{
|
|
println!("Uploading media from '{}'", media_path.display());
|
|
|
|
let typ = match media_path.extension().and_then(|os| os.to_str()).unwrap_or("") {
|
|
"jpg" | "jpeg" => media_types::image_jpg(),
|
|
"gif" => media_types::image_gif(),
|
|
"png" => media_types::image_png(),
|
|
"webp" => media_types::image_webp(),
|
|
"mp4" => media_types::video_mp4(),
|
|
_ => {
|
|
eprintln!("Format not recognized, must be one of [jpg, jpeg, gif, png, webp, mp4]");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
let bytes = std::fs::read(media_path)?;
|
|
let handle = upload_media(&bytes, &typ, &config.token).await?;
|
|
|
|
set_metadata(&handle.id, media_alt_text, &config.token).await?;
|
|
|
|
println!("Media uploaded");
|
|
|
|
// Wait 60 seconds for processing
|
|
println!("Waiting for media to finish processing...");
|
|
|
|
for ct in 0..=60u32 {
|
|
match get_status(handle.id.clone(), &config.token).await?.progress {
|
|
None | Some(ProgressInfo::Success) => {
|
|
println!("\nMedia sucessfully processed");
|
|
break;
|
|
}
|
|
Some(ProgressInfo::Pending(_)) | Some(ProgressInfo::InProgress(_)) => {
|
|
print!(".");
|
|
stdout().flush()?;
|
|
delay_for(Duration::from_secs(1)).await;
|
|
}
|
|
Some(ProgressInfo::Failed(err)) => Err(err)?,
|
|
}
|
|
if ct == 60 {
|
|
bail!("Error: timeout");
|
|
}
|
|
}
|
|
|
|
Ok(handle)
|
|
}
|