initial commit
This commit is contained in:
commit
2351ad6342
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
/.idea
|
||||
Cargo.lock
|
||||
*.db*
|
|
@ -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"
|
|
@ -0,0 +1,8 @@
|
|||
# ferry
|
||||
a multipurpose irc bot for ezbake
|
||||
|
||||
## notable features
|
||||
- html scraping
|
||||
- plaintext tcp protocol capture
|
||||
- web portal
|
||||
- api
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
create table if not exists messages (
|
||||
id integer primary key not null,
|
||||
nick text not null,
|
||||
message text not null
|
||||
);
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue