This commit is contained in:
commit
b365973fd2
421
.drone.yml
Normal file
421
.drone.yml
Normal 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
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/Config.toml
|
||||
/sled
|
1147
Cargo.lock
generated
Normal file
1147
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal 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
8
Config.example.toml
Normal 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
10
docker/drone/Dockerfile
Normal 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"]
|
25
docker/drone/manifest.tmpl
Normal file
25
docker/drone/manifest.tmpl
Normal 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
|
8
docker/prod/docker-compose.yml
Normal file
8
docker/prod/docker-compose.yml
Normal 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
17660
fursona.txt
Normal file
File diff suppressed because it is too large
Load diff
43
src/color.rs
Normal file
43
src/color.rs
Normal 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
31
src/config.rs
Normal 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
88
src/database.rs
Normal 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
1
src/error.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub(crate) type AnyError = Box<dyn std::error::Error + Send + Sync>;
|
92
src/main.rs
Normal file
92
src/main.rs
Normal 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, ¬if.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
165
src/session.rs
Normal 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 {}
|
99
src/session/notifications.rs
Normal file
99
src/session/notifications.rs
Normal 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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue