initial commit

This commit is contained in:
vance 2023-01-05 18:45:11 -08:00
commit 2351ad6342
7 changed files with 385 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=sqlite:ferry.db

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target
/.idea
Cargo.lock
*.db*

25
Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "ferry"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
futures = "*"
anyhow = "*"
tokio = { version = "*", features = ["full"] }
tokio-util = { version = "*", features = ["codec"] }
tokio-stream = { version = "*", features = ["net"] }
sqlx = { version = "*", features = ["runtime-tokio-rustls", "all-databases"] }
reqwest = { version = "*", features = ["rustls-tls", "cookies", "gzip", "brotli", "deflate", "json", "multipart", "stream"] }
scraper = "*"
axum = { version = "*", features = ["form", "json"] }
irc = "*"
serde = { version = "*" }
serde_derive = "*"
toml = "*"
[profile.release]
lto = "thin"
opt-level = "z"

8
README.md Normal file
View File

@ -0,0 +1,8 @@
# ferry
a multipurpose irc bot for ezbake
## notable features
- html scraping
- plaintext tcp protocol capture
- web portal
- api

13
config.toml Normal file
View File

@ -0,0 +1,13 @@
[irc]
nickname = "ferry"
server = "localhost"
port = 6697
use_tls = true
channels = ["#hotdog", "#ezbake"]
[database]
url = "sqlite:ferry.db"
[portal]
ip = "127.0.0.1"
port = 8080

View File

@ -0,0 +1,5 @@
create table if not exists messages (
id integer primary key not null,
nick text not null,
message text not null
);

329
src/main.rs Normal file
View File

@ -0,0 +1,329 @@
use std::error::Error;
use std::fmt::Write;
use std::fs;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use axum::{
extract::{Extension, Path as HttpPath},
routing::get,
Router, Server as HttpServer,
};
use futures::StreamExt;
use irc::client::prelude::{
Client as IrcClient, Command as IrcCommand, Config as IrcConfig, Message as IrcMessage,
Sender as IrcSender,
};
use irc::client::ClientStream as IrcStream;
use reqwest::Client as HttpClient;
use scraper::{Html, Selector};
use serde_derive::Deserialize;
use sqlx::{query, SqlitePool};
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio::time::timeout;
use tokio_util::codec::{AnyDelimiterCodec, Framed, LinesCodec};
struct ResponseError(anyhow::Error);
impl<T: Error + Send + Sync + 'static> From<T> for ResponseError {
fn from(error: T) -> Self {
Self(error.into())
}
}
impl axum::response::IntoResponse for ResponseError {
fn into_response(self) -> axum::response::Response {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
self.0.to_string(),
)
.into_response()
}
}
type ResultResponse<T> = std::result::Result<T, ResponseError>;
struct FerryPortal;
impl FerryPortal {
fn new() -> Self {
Self {}
}
async fn get_nick(Extension(this): Extension<Arc<FerryState>>) -> ResultResponse<String> {
this.irc.sender.send_privmsg("#hotdog", "VISITOR~!!")?;
this.irc.sender.send_privmsg("#hotdog", "hey there")?;
let nick = this.irc.client.current_nickname().to_owned();
Ok(nick)
}
async fn send_message(
Extension(this): Extension<Arc<FerryState>>,
HttpPath((target, message)): HttpPath<(String, String)>,
) -> ResultResponse<String> {
this.irc.sender.send_privmsg(target, message.clone())?;
Ok(message)
}
async fn users(
Extension(this): Extension<Arc<FerryState>>,
HttpPath(channel): HttpPath<String>,
) -> ResultResponse<String> {
let mut page = String::new();
if let Some(users) = this.irc.client.list_users(&channel) {
for user in users {
write!(page, "{}\r\n", user.get_nickname())?;
}
};
Ok(page)
}
async fn chats(Extension(this): Extension<Arc<FerryState>>) -> ResultResponse<String> {
let mut page = String::new();
let mut conn = this.db.acquire().await?;
let chats = sqlx::query!(r#"select id, nick, message from messages order by id"#)
.fetch_all(&mut conn)
.await?;
for chat in chats {
write!(page, "{}: {}\r\n", chat.nick, chat.message)?;
}
Ok(page)
}
async fn serve(this: Arc<FerryState>) -> Result<()> {
let listen_addr = format!("{}:{}", this.config.portal.ip, this.config.portal.port);
let app = Router::new()
.route("/", get(Self::get_nick))
.route("/msg/:target/:message", get(Self::send_message))
.route("/chats", get(Self::chats))
.route("/users/:channel", get(Self::users))
.layer(Extension(this));
HttpServer::bind(&listen_addr.parse()?)
.serve(app.into_make_service())
.await?;
Ok(())
}
}
#[derive(Deserialize)]
struct FerryConfig {
irc: IrcConfig,
database: FerryDatabaseConfig,
portal: FerryPortalConfig,
}
#[derive(Deserialize)]
struct FerryDatabaseConfig {
url: String,
}
#[derive(Deserialize)]
struct FerryPortalConfig {
ip: String,
port: u16,
}
impl FerryConfig {
fn from_toml<P: AsRef<Path>>(file_path: P) -> Result<Self> {
let contents = fs::read_to_string(file_path)?;
Ok(toml::from_str::<Self>(&contents)?)
}
}
struct FerryState {
config: FerryConfig,
db: SqlitePool,
http: HttpClient,
irc: FerryIrc,
portal: FerryPortal,
}
impl FerryState {
fn new(
config: FerryConfig,
db: SqlitePool,
http: HttpClient,
irc: FerryIrc,
portal: FerryPortal,
) -> Arc<Self> {
Arc::new(Self {
config,
db,
http,
irc,
portal,
})
}
}
struct FerryIrc {
client: IrcClient,
sender: IrcSender,
stream: Mutex<IrcStream>,
}
impl FerryIrc {
fn new(client: IrcClient, sender: IrcSender, stream: IrcStream) -> Self {
Self {
client,
sender,
stream: Mutex::new(stream),
}
}
async fn session(this: Arc<FerryState>, target: String, host: String) -> Result<()> {
let stream = TcpStream::connect(host).await?;
let mut stream = Framed::new(
stream,
AnyDelimiterCodec::new(b"\n".to_vec(), b"\r\n".to_vec()),
);
while let Some(line) = stream.next().await.transpose()? {
this.irc.sender.send_privmsg(target.clone(), String::from_utf8_lossy(&*line).trim_end())?;
}
Ok(())
}
async fn select(
this: Arc<FerryState>,
target: String,
url: String,
selector: String,
) -> Result<()> {
let mut reply = String::new();
let request = this.http.get(url);
if let Some(document) = match request.send().await {
Ok(response) => match response.text().await {
Ok(text) => Some(text),
Err(e) => {
write!(reply, "{:?}", e)?;
None
}
},
Err(e) => {
write!(reply, "{:?}", e)?;
None
}
} {
let document = Html::parse_document(&document);
if let Some(selector) = match Selector::parse(&selector) {
Ok(selector) => Some(selector),
Err(e) => {
write!(reply, "{:?}", e)?;
None
}
} {
reply = match document.select(&selector).next() {
Some(element) => element.inner_html(),
None => "Nothing found".to_owned(),
};
}
}
this.irc.sender.send_privmsg(target, reply)?;
Ok(())
}
async fn handle_privmsg(this: Arc<FerryState>, message: IrcMessage) -> Result<()> {
let (channel, message_text) = match &message.command {
IrcCommand::PRIVMSG(c, m) => (c.clone(), m.clone()),
_ => todo!(),
};
println!("{}: {}", channel, message_text);
let mut conn = this.db.acquire().await?;
let nick = message.source_nickname().unwrap_or("");
sqlx::query!(
r#"insert into messages ( nick, message ) values ( ?1, ?2 )"#,
nick,
message_text,
)
.execute(&mut conn)
.await?;
if let Some(channel) = message_text.strip_prefix("!join ") {
this.irc.sender.send_join(channel)?
}
if let Some(nick) = message_text.strip_prefix("!quote ") {
if let Some(quote) = query!(
r#"select message from messages where nick = ?1 order by random() limit 1"#,
nick,
)
.fetch_optional(&mut conn)
.await?
{
this.irc.sender.send_privmsg(
channel.clone(),
quote.message.unwrap_or_else(|| "".to_owned()),
)?;
}
}
if let Some(command) = message_text.strip_prefix("!select ") {
let mut command = command.splitn(2, ' ');
if let Some((url, selector)) = command.next().zip(command.next()) {
Self::select(
this.clone(),
channel.to_owned(),
url.to_owned(),
selector.to_owned(),
)
.await?;
}
}
if let Some(host) = message_text.strip_prefix("!session ") {
Self::session(this.clone(), channel.to_owned(), host.to_owned()).await?;
}
Ok(())
}
async fn handle_message(this: Arc<FerryState>, message: IrcMessage) -> Result<()> {
match &message.command {
IrcCommand::PRIVMSG(_, _) => Self::handle_privmsg(this.clone(), message.clone()).await,
_ => Ok(()),
}
}
async fn serve(this: Arc<FerryState>) -> Result<()> {
this.irc.client.identify()?;
while let Some(message) = this.irc.stream.lock().await.next().await.transpose()? {
tokio::spawn(Self::handle_message(this.clone(), message));
}
Ok(())
}
}
struct Ferry {
state: Arc<FerryState>,
}
impl Ferry {
async fn new(config: FerryConfig) -> Result<Self> {
let db = SqlitePool::connect(&config.database.url).await?;
let http = HttpClient::builder()
.timeout(Duration::from_secs(30))
.build()?;
let mut irc_client = IrcClient::from_config(config.irc.clone()).await?;
let irc_sender = irc_client.sender();
let irc_stream = irc_client.stream()?;
let irc = FerryIrc::new(irc_client, irc_sender, irc_stream);
let portal = FerryPortal::new();
let state = FerryState::new(config, db, http, irc, portal);
Ok(Self { state })
}
async fn serve(self) -> Result<()> {
tokio::spawn(FerryIrc::serve(self.state.clone()));
FerryPortal::serve(self.state.clone()).await
}
}
#[tokio::main]
async fn main() -> Result<()> {
let config = FerryConfig::from_toml("config.toml")?;
let ferry = Ferry::new(config).await?;
ferry.serve().await
}