fursonabot
Some checks failed
continuous-integration/drone/tag Build is failing

This commit is contained in:
asonix 2022-12-30 17:27:42 -06:00
commit b365973fd2
16 changed files with 19820 additions and 0 deletions

421
.drone.yml Normal file
View file

@ -0,0 +1,421 @@
kind: pipeline
type: docker
name: clippy
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: clippy
image: asonix/rust-builder:latest-linux-amd64
pull: always
commands:
- rustup component add clippy
- cargo clippy -- -D warnings
trigger:
event:
- push
- pull_request
- tag
---
kind: pipeline
type: docker
name: tests
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: tests
image: asonix/rust-builder:latest-linux-amd64
pull: always
commands:
- cargo test
trigger:
event:
- push
- pull_request
- tag
---
kind: pipeline
type: docker
name: check-amd64
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: check
image: asonix/rust-builder:latest-linux-amd64
pull: always
commands:
- cargo check --target=$TARGET
trigger:
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: build-amd64
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: build
image: asonix/rust-builder:latest-linux-amd64
pull: always
commands:
- cargo build --target=$TARGET --release
- $TOOL-strip target/$TARGET/release/fursonabot
- cp target/$TARGET/release/fursonabot .
- cp fursonabot fursonabot-linux-amd64
- name: push
image: plugins/docker:20
settings:
username: asonix
password:
from_secret: dockerhub_token
repo: asonix/fursonabot
dockerfile: docker/drone/Dockerfile
auto_tag: true
auto_tag_suffix: linux-amd64
build_args:
- REPO_ARCH=amd64
- name: publish
image: plugins/gitea-release:1
settings:
api_key:
from_secret: gitea_token
base_url: https://git.asonix.dog
files:
- fursonabot-linux-amd64
depends_on:
- clippy
- tests
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: check-arm64v8
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: check
image: asonix/rust-builder:latest-linux-arm64v8
pull: always
commands:
- cargo check --target=$TARGET
trigger:
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: build-arm64v8
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: build
image: asonix/rust-builder:latest-linux-arm64v8
pull: always
commands:
- cargo build --target=$TARGET --release
- $TOOL-strip target/$TARGET/release/fursonabot
- cp target/$TARGET/release/fursonabot .
- cp fursonabot fursonabot-linux-arm64v8
- name: push
image: plugins/docker:20
settings:
username: asonix
password:
from_secret: dockerhub_token
repo: asonix/fursonabot
dockerfile: docker/drone/Dockerfile
auto_tag: true
auto_tag_suffix: linux-arm64v8
build_args:
- REPO_ARCH=arm64v8
- name: publish
image: plugins/gitea-release:1
settings:
api_key:
from_secret: gitea_token
base_url: https://git.asonix.dog
files:
- fursonabot-linux-arm64v8
depends_on:
- clippy
- tests
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: check-arm32v7
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: check
image: asonix/rust-builder:latest-linux-arm32v7
pull: always
commands:
- cargo check --target=$TARGET
trigger:
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: build-arm32v7
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: build
image: asonix/rust-builder:latest-linux-arm32v7
pull: always
commands:
- cargo build --target=$TARGET --release
- $TOOL-strip target/$TARGET/release/fursonabot
- cp target/$TARGET/release/fursonabot .
- cp fursonabot fursonabot-linux-arm32v7
- name: push
image: plugins/docker:20
settings:
username: asonix
password:
from_secret: dockerhub_token
repo: asonix/fursonabot
dockerfile: docker/drone/Dockerfile
auto_tag: true
auto_tag_suffix: linux-arm32v7
build_args:
- REPO_ARCH=arm32v7
- name: publish
image: plugins/gitea-release:1
settings:
api_key:
from_secret: gitea_token
base_url: https://git.asonix.dog
files:
- fursonabot-linux-arm32v7
depends_on:
- clippy
- tests
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: manifest
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: manifest
image: plugins/manifest:1
settings:
username: asonix
password:
from_secret: dockerhub_token
dump: true
auto_tag: true
ignore_missing: true
spec: docker/drone/manifest.tmpl
depends_on:
- build-amd64
- build-arm64v8
- build-arm32v7
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: publish-crate
platform:
arch: amd64
clone:
disable: true
steps:
- name: clone
image: alpine/git:latest
user: root
commands:
- git clone $DRONE_GIT_HTTP_URL .
- git checkout $DRONE_COMMIT
- chown -R 991:991 .
- name: publish
image: asonix/rust-builder:latest-linux-amd64
pull: always
environment:
CRATES_IO_TOKEN:
from_secret: crates_io_token
commands:
- cargo publish --token $CRATES_IO_TOKEN
depends_on:
- build-amd64
- build-arm64v8
- build-arm32v7
trigger:
event:
- tag

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/Config.toml
/sled

1147
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "fursonabot"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
parse_link_header = "0.3.3"
rand = "0.8"
reqwest = { version = "0.11", default-features = false, features = [
"json",
"rustls-tls",
] }
serde = { version = "1", features = ["derive"] }
sled = "0.34.7"
tokio = { version = "1.23.0", features = ["full"] }
toml = "0.5"
url = { version = "2", features = ["serde"] }

8
Config.example.toml Normal file
View file

@ -0,0 +1,8 @@
[database]
path = "./sled/db-0-34"
[mastodon]
server = "https://masto.asonix.dog"
client_id = "CLIENT ID"
client_secret = "CLIENT SECRET"
authorization_code = "AUTHORIZATION CODE"

10
docker/drone/Dockerfile Normal file
View file

@ -0,0 +1,10 @@
ARG REPO_ARCH
FROM asonix/rust-runner:latest-linux-$REPO_ARCH
COPY fursonabot /usr/local/bin/fursonabot
USER app
VOLUME /opt/app
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/local/bin/fursonabot"]

View file

@ -0,0 +1,25 @@
image: asonix/fursonabot:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
-
image: asonix/fursonabot:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
-
image: asonix/fursonabot:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64v8
platform:
architecture: arm64
os: linux
variant: v8
-
image: asonix/fursonabot:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm32v7
platform:
architecture: arm
os: linux
variant: v7

View file

@ -0,0 +1,8 @@
version: '3.3'
services:
fursonabot:
image: asonix/fursonabot:v0.1.0
restart: always
volumes:
- ./volumes/fursonabot:/opt/app

17660
fursona.txt Normal file

File diff suppressed because it is too large Load diff

43
src/color.rs Normal file
View file

@ -0,0 +1,43 @@
use crate::error::AnyError;
use rand::Rng;
use reqwest::Client;
#[derive(serde::Deserialize)]
struct Color {
name: ColorName,
}
#[derive(serde::Deserialize)]
struct ColorName {
value: String,
}
#[derive(Debug)]
struct ColorFailure(String);
pub(crate) async fn generate_color(client: &Client) -> Result<String, AnyError> {
let mut rng = rand::thread_rng();
let [r, g, b]: [u8; 3] = rng.gen();
let url = format!("http://www.thecolorapi.com/id?rgb={},{},{}", r, g, b);
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(ColorFailure(response.text().await?).into());
}
let color: Color = response.json().await?;
Ok(color.name.value.to_lowercase())
}
impl std::fmt::Display for ColorFailure {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "Failed to fetch color: {}", self.0)
}
}
impl std::error::Error for ColorFailure {}

31
src/config.rs Normal file
View file

@ -0,0 +1,31 @@
use std::path::{Path, PathBuf};
use url::Url;
#[derive(serde::Deserialize)]
pub(crate) struct Config {
pub(crate) database: DatabaseConfig,
pub(crate) mastodon: MastodonConfig,
}
#[derive(serde::Deserialize)]
pub(crate) struct MastodonConfig {
pub(crate) server: Url,
pub(crate) client_id: String,
pub(crate) client_secret: String,
pub(crate) authorization_code: String,
}
#[derive(serde::Deserialize)]
pub(crate) struct DatabaseConfig {
pub(crate) path: PathBuf,
}
impl Config {
pub fn read<P: AsRef<Path>>(path: P) -> Result<Self, crate::error::AnyError> {
let file = std::fs::read_to_string(path)?;
let config = toml::from_str(&file)?;
Ok(config)
}
}

88
src/database.rs Normal file
View file

@ -0,0 +1,88 @@
use crate::{config::DatabaseConfig, error::AnyError};
use sled::{Db, Tree};
use std::sync::Arc;
#[derive(Clone)]
pub(crate) struct Database {
inner: Arc<Inner>,
}
struct Inner {
config: Tree,
state: Tree,
_db: Db,
}
impl Database {
pub(crate) fn open(config: &DatabaseConfig) -> Result<Database, AnyError> {
let db = sled::Config::new().path(&config.path).open()?;
let config = db.open_tree("fursonabot-config")?;
let state = db.open_tree("fursonabot-state")?;
Ok(Database {
inner: Arc::new(Inner {
config,
state,
_db: db,
}),
})
}
async fn with_inner<F, T>(&self, f: F) -> Result<T, AnyError>
where
F: FnOnce(&Inner) -> Result<T, AnyError> + Send + 'static,
T: Send + 'static,
{
let this = Arc::clone(&self.inner);
let out = tokio::task::spawn_blocking(move || (f)(&this)).await??;
Ok(out)
}
pub(crate) async fn save_token(&self, token: String) -> Result<(), AnyError> {
self.with_inner(|inner| inner.save_token(token)).await
}
pub(crate) async fn fetch_token(&self) -> Result<Option<String>, AnyError> {
self.with_inner(Inner::fetch_token).await
}
pub(crate) async fn last_notification(&self) -> Result<Option<String>, AnyError> {
self.with_inner(Inner::last_notification).await
}
pub(crate) async fn save_last_notification(&self, id: String) -> Result<(), AnyError> {
self.with_inner(|inner| inner.save_last_notification(id))
.await
}
}
impl Inner {
fn save_token(&self, token: String) -> Result<(), AnyError> {
self.config.insert("token", token.as_bytes())?;
Ok(())
}
fn fetch_token(&self) -> Result<Option<String>, AnyError> {
let Some(token_ivec) = self.config.get("token")? else { return Ok(None); };
let token = String::from_utf8(token_ivec[..].to_vec())?;
Ok(Some(token))
}
fn last_notification(&self) -> Result<Option<String>, AnyError> {
let Some(token_ivec) = self.state.get("last_notification")? else { return Ok(None); };
let token = String::from_utf8(token_ivec[..].to_vec())?;
Ok(Some(token))
}
fn save_last_notification(&self, id: String) -> Result<(), AnyError> {
self.state.insert("last_notification", id.as_bytes())?;
Ok(())
}
}

1
src/error.rs Normal file
View file

@ -0,0 +1 @@
pub(crate) type AnyError = Box<dyn std::error::Error + Send + Sync>;

92
src/main.rs Normal file
View file

@ -0,0 +1,92 @@
use self::{config::Config, database::Database, session::Session};
use rand::seq::SliceRandom;
use reqwest::Client;
use std::{
fs::File,
io::{BufRead, BufReader},
};
mod color;
mod config;
mod database;
mod error;
mod session;
const USER_AGENT: &str = "fursonabot";
const VOWELS: &[char] = &['a', 'e', 'i', 'o', 'u'];
#[tokio::main]
async fn main() -> Result<(), error::AnyError> {
let config = Config::read("./Config.toml")?;
let database = Database::open(&config.database)?;
let client = build_client()?;
let session = if let Some(token) = database.fetch_token().await? {
Session::from_token(token, &config.mastodon, client.clone())?
} else {
let session = Session::auth(&config.mastodon, client.clone()).await?;
database.save_token(session.token().to_owned()).await?;
session
};
let fursonas = File::open("./fursona.txt")?;
let fursonas: Vec<_> = BufReader::new(fursonas).lines().collect::<Result<_, _>>()?;
loop {
let last_notification = database.last_notification().await?;
let mut notifs = session.notifications_since(last_notification).await?;
while let Some(notif) = notifs.next().await? {
if notif.content().to_lowercase().contains("give me a fursona") {
let color = color::generate_color(&client).await?;
if color.is_empty() {
eprintln!("Got empty color!!!");
continue;
}
let species = fursonas
.choose(&mut rand::thread_rng())
.expect("fursonas is not empty");
let colored = if color
.chars()
.next()
.map(|c| VOWELS.contains(&c))
.unwrap_or(false)
{
format!("an {}", color)
} else {
format!("a {}", color)
};
let text = format!(
"@{}, your fursona is {} colored {}",
notif.account(),
colored,
species
);
session.reply_to(&text, &notif.status).await?;
}
database
.save_last_notification(notif.id().to_owned())
.await?;
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
fn build_client() -> Result<Client, crate::error::AnyError> {
Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(From::from)
}

165
src/session.rs Normal file
View file

@ -0,0 +1,165 @@
use crate::{
config::MastodonConfig,
error::AnyError,
session::notifications::{NotificationsSince, Page, Status},
};
use reqwest::Client;
use url::Url;
mod notifications;
const TOKEN_PATH: &str = "/oauth/token";
const NOTIFICATIONS_PATH: &str = "/api/v1/notifications";
const STATUSES_PATH: &str = "/api/v1/statuses";
pub(crate) struct Session {
server: Url,
token: String,
client: Client,
}
#[derive(serde::Serialize)]
struct OAuthParams<'a> {
client_id: &'a str,
client_secret: &'a str,
code: &'a str,
redirect_uri: &'static str,
grant_type: &'static str,
scope: &'static str,
}
#[derive(serde::Deserialize)]
struct Token {
access_token: String,
}
#[derive(Debug)]
struct AuthFailure(String);
#[derive(Debug)]
struct PostFailure(String);
#[derive(serde::Serialize)]
struct StatusForm<'a> {
status: &'a str,
visibility: notifications::Visibility,
in_reply_to_id: &'a str,
}
impl Session {
fn get(&self, url: &url::Url) -> reqwest::RequestBuilder {
self.client.get(url.as_str()).bearer_auth(&self.token)
}
fn post(&self, url: &url::Url) -> reqwest::RequestBuilder {
self.client.post(url.as_str()).bearer_auth(&self.token)
}
pub(crate) async fn reply_to(&self, text: &str, status: &Status) -> Result<(), AnyError> {
let mut url = self.server.clone();
url.set_path(STATUSES_PATH);
let response = self
.post(&url)
.form(&StatusForm {
status: text,
visibility: status.visibility,
in_reply_to_id: &status.id,
})
.send()
.await?;
if !response.status().is_success() {
return Err(PostFailure(response.text().await?).into());
}
Ok(())
}
pub(crate) async fn notifications_since(
&self,
since_id: Option<String>,
) -> Result<NotificationsSince<'_>, AnyError> {
let mut url = self.server.clone();
url.set_path(NOTIFICATIONS_PATH);
let min_id = if let Some(id) = since_id {
id
} else {
String::new()
};
let query = format!("types[]=mention&min_id={}", min_id);
url.set_query(Some(&query));
let response = self.get(&url).send().await?;
let page = Page::try_from_response(response).await?;
Ok(NotificationsSince {
session: self,
current: page,
})
}
pub(crate) fn from_token(
token: String,
config: &MastodonConfig,
client: Client,
) -> Result<Self, AnyError> {
Ok(Self {
server: config.server.clone(),
token,
client,
})
}
pub(crate) async fn auth(config: &MastodonConfig, client: Client) -> Result<Self, AnyError> {
let mut token_url = config.server.clone();
token_url.set_path(TOKEN_PATH);
let response = client
.post(token_url.as_str())
.form(&OAuthParams {
client_id: &config.client_id,
client_secret: &config.client_secret,
code: &config.authorization_code,
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
grant_type: "authorization_code",
scope: "read write",
})
.send()
.await?;
if !response.status().is_success() {
return Err(AuthFailure(response.text().await?).into());
}
let token: Token = response.json().await?;
Ok(Self {
server: config.server.clone(),
token: token.access_token,
client,
})
}
pub(crate) fn token(&self) -> &str {
&self.token
}
}
impl std::fmt::Display for AuthFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Failed auth: {}", self.0)
}
}
impl std::fmt::Display for PostFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Failed post: {}", self.0)
}
}
impl std::error::Error for AuthFailure {}
impl std::error::Error for PostFailure {}

View file

@ -0,0 +1,99 @@
use crate::{error::AnyError, session::Session};
use reqwest::{header::LINK, Response};
pub(crate) struct NotificationsSince<'a> {
pub(super) session: &'a Session,
pub(super) current: Page,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum MentionType {
Mention,
}
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Visibility {
Public,
Unlisted,
Private,
Direct,
}
#[derive(Debug, serde::Deserialize)]
pub(crate) struct Mention {
id: String,
#[serde(rename = "type")]
_ty: MentionType,
pub(crate) status: Status,
}
#[derive(Debug, serde::Deserialize)]
pub(crate) struct Status {
pub(super) id: String,
content: String,
pub(super) visibility: Visibility,
account: Account,
}
#[derive(Debug, serde::Deserialize)]
pub(crate) struct Account {
acct: String,
}
pub(super) struct Page {
items: Vec<Mention>,
prev_link: Option<url::Url>,
}
impl Page {
pub(super) async fn try_from_response(response: Response) -> Result<Self, AnyError> {
let prev_link = if let Some(link) = response
.headers()
.get(LINK)
.and_then(|link| link.to_str().ok())
{
let links = parse_link_header::parse_with_rel(link)?;
links.get("prev").and_then(|link| link.raw_uri.parse().ok())
} else {
None
};
let items: Vec<Mention> = response.json().await?;
Ok(Self { prev_link, items })
}
}
impl<'a> NotificationsSince<'a> {
pub(crate) async fn next(&mut self) -> Result<Option<Mention>, AnyError> {
loop {
if let Some(item) = self.current.items.pop() {
return Ok(Some(item));
}
let Some(url) = &self.current.prev_link else { break; };
let response = self.session.get(url).send().await?;
self.current = Page::try_from_response(response).await?;
}
Ok(None)
}
}
impl Mention {
pub(crate) fn id(&self) -> &str {
&self.id
}
pub(crate) fn content(&self) -> &str {
&self.status.content
}
pub(crate) fn account(&self) -> &str {
&self.status.account.acct
}
}