Compare commits
44 Commits
Author | SHA1 | Date |
---|---|---|
LordMZTE | cda1743f0f | |
LordMZTE | c270953ab2 | |
LordMZTE | 653592649d | |
LordMZTE | 8e963c1852 | |
LordMZTE | ae0bd48c38 | |
LordMZTE | e72b92a678 | |
LordMZTE | c387ad566a | |
LordMZTE | 54ce90d090 | |
Timo Ley | 70159a085d | |
Timo Ley | 3c6ecd6649 | |
Timo Ley | df15a1c3d5 | |
LordMZTE | 28f610a1d2 | |
LordMZTE | 9c1f1c44ac | |
LordMZTE | aea4b48c2b | |
LordMZTE | 5b115d4bbc | |
LordMZTE | 05e00f412b | |
LordMZTE | 298c0a7f6f | |
LordMZTE | 5007f5ddba | |
LordMZTE | 485d44c56f | |
LordMZTE | ccf8c65c80 | |
LordMZTE | 568607fe3e | |
LordMZTE | 58569bae3f | |
LordMZTE | 0e4664ceeb | |
LordMZTE | 0a3950af4f | |
LordMZTE | 8106dc3966 | |
LordMZTE | 6322919e6d | |
LordMZTE | cd1875240d | |
LordMZTE | d0577cc66f | |
LordMZTE | 48d479973b | |
LordMZTE | d8a846a8cb | |
LordMZTE | a98c8cad14 | |
LordMZTE | 89b4488050 | |
LordMZTE | f058473b59 | |
LordMZTE | b173b67f61 | |
LordMZTE | 1606b25a36 | |
LordMZTE | 615d0b388d | |
LordMZTE | 0653042747 | |
LordMZTE | 1b9dfc0d78 | |
LordMZTE | 8cb7ea697d | |
LordMZTE | 9e4af039f4 | |
LordMZTE | 32e5bcaa35 | |
LordMZTE | 87fc6a9f0b | |
LordMZTE | 5e4ff37992 | |
LordMZTE | d35ae7586d |
74
.drone.yml
74
.drone.yml
|
@ -1,46 +1,46 @@
|
|||
kind: pipeline
|
||||
type: docker
|
||||
name: tests
|
||||
steps:
|
||||
- name: test-linux
|
||||
image: rust
|
||||
commands:
|
||||
- cargo test -v
|
||||
- name: test-linux
|
||||
image: rust
|
||||
commands:
|
||||
- apt update
|
||||
- apt install -y cmake
|
||||
- cargo test -v
|
||||
|
||||
- name: clippy-linux
|
||||
image: rust
|
||||
commands:
|
||||
- apt update
|
||||
- apt install -y cmake
|
||||
- rustup component add clippy
|
||||
- cargo clippy
|
||||
|
||||
# Try to build docker image
|
||||
- name: test-docker-build
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: lordmzte/ruff
|
||||
# Don't push
|
||||
dry_run: true
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
steps:
|
||||
- name: release-linux
|
||||
image: rust
|
||||
commands:
|
||||
- cargo build --release -v
|
||||
|
||||
- name: release-win
|
||||
image: lordmzte/rust-win
|
||||
commands:
|
||||
- cargo build --release --target x86_64-pc-windows-gnu -v
|
||||
|
||||
- name: publish
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
base_url: https://data.tilera.xyz/git
|
||||
api_key:
|
||||
from_secret: gitea_token
|
||||
note: CHANGELOG.md
|
||||
title: tag-${DRONE_TAG}
|
||||
files:
|
||||
- target/release/ruff
|
||||
- target/x86_64-pc-windows-gnu/release/ruff.exe
|
||||
when:
|
||||
event: tag
|
||||
depends_on:
|
||||
- release-linux
|
||||
- release-win
|
||||
- name: release-linux
|
||||
image: rust
|
||||
commands:
|
||||
- apt update
|
||||
- apt install -y cmake
|
||||
- cargo build --release -v
|
||||
- strip target/release/ruff
|
||||
|
||||
- name: publish
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
base_url: https://tilera.xyz/git
|
||||
api_key:
|
||||
from_secret: gitea_token
|
||||
note: CHANGELOG.md
|
||||
title: tag-${DRONE_TAG}
|
||||
files:
|
||||
- target/release/ruff
|
||||
when:
|
||||
event: [tag]
|
||||
depends_on:
|
||||
- release-linux
|
||||
|
|
|
@ -1,5 +1,2 @@
|
|||
v0.1.0
|
||||
# ruff
|
||||
initial release
|
||||
- does everything to old uffbot could
|
||||
|
||||
# 0.2.8
|
||||
- fix uffch id
|
||||
|
|
54
Cargo.toml
54
Cargo.toml
|
@ -1,40 +1,42 @@
|
|||
[package]
|
||||
name = "ruff"
|
||||
version = "0.1.0"
|
||||
version = "0.2.8"
|
||||
authors = ["LordMZTE <lord@mzte.de>"]
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1.42"
|
||||
bytes = "0.5"
|
||||
chrono = "0.4.19"
|
||||
clap = "2.33.3"
|
||||
dirs = "3.0.1"
|
||||
fern = "0.6.0"
|
||||
log = "0.4.13"
|
||||
once_cell = "1.5.2"
|
||||
pin-project-lite = "0.2.4"
|
||||
serde_json = "1.0.61"
|
||||
structopt = "0.3.21"
|
||||
thiserror = "1.0.23"
|
||||
anyhow = "1.0.53"
|
||||
bincode = "1.3.3"
|
||||
clap = "3.0.10"
|
||||
env_logger = "0.9.0"
|
||||
log = "0.4.14"
|
||||
mime = "0.3.16"
|
||||
mime_guess = "2.0.3"
|
||||
rand = "0.8.4"
|
||||
serde_json = "1.0.78"
|
||||
sled = "0.34.7"
|
||||
structopt = "0.3.26"
|
||||
toml = "0.5.8"
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "0.2"
|
||||
features = [
|
||||
"macros",
|
||||
"fs",
|
||||
"stream",
|
||||
]
|
||||
[dependencies.libjens]
|
||||
git = "https://tilera.xyz/git/lordmzte/libjens.git"
|
||||
rev = "1.1.0"
|
||||
|
||||
[dependencies.matrix-sdk]
|
||||
git = "https://github.com/matrix-org/matrix-rust-sdk.git"
|
||||
rev = "c79e62d"
|
||||
features = ["encryption"]
|
||||
|
||||
[dependencies.url]
|
||||
version = "2.2.2"
|
||||
features = ["serde"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0"
|
||||
version = "1.0.135"
|
||||
features = ["derive"]
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.10"
|
||||
features = ["stream"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.15.0"
|
||||
features = ["macros"]
|
||||
|
|
24
Dockerfile
24
Dockerfile
|
@ -1,24 +0,0 @@
|
|||
# this uses buster as alpine would always segfault
|
||||
FROM rust:buster as builder
|
||||
LABEL maintainer="LordMZTE <https://github.com/lordmzte>"
|
||||
|
||||
RUN apt install \
|
||||
gcc \
|
||||
libssl-dev
|
||||
|
||||
WORKDIR /usr/src/ruff
|
||||
COPY Cargo.toml ./
|
||||
COPY src/ src/
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:buster
|
||||
COPY --from=builder /usr/src/ruff/target/release/ruff /usr/bin
|
||||
|
||||
RUN apt update
|
||||
RUN apt install \
|
||||
libssl1.1
|
||||
|
||||
VOLUME /ruffconfig
|
||||
CMD /usr/bin/ruff -c /ruffconfig/config.toml
|
||||
|
12
README.md
12
README.md
|
@ -2,19 +2,9 @@
|
|||
|
||||
[![Build Status](https://drone.tilera.xyz/api/badges/LordMZTE/RUFF/status.svg)](https://drone.tilera.xyz/LordMZTE/RUFF)
|
||||
|
||||
The successor to ITbyHF's crappy golang UFFbot written in rust and compatible with most chat platforms through matterbridge.
|
||||
The successor to jonasled's crappy C# UFFbot written in rust.
|
||||
|
||||
## Compiling
|
||||
1. `cargo build --release`
|
||||
2. artifacts in `target/release`
|
||||
|
||||
## Setting up the dev environment
|
||||
There is a docker-compose workspace for development. It contains a matterbridge server for RUFF to connect to, and an IRC server as an endpoint.
|
||||
1. start to containers with `cd testenv && docker-compose up`
|
||||
2. connect to the irc server at `localhost:6667` with an IRC client of your choice
|
||||
3. the matterbridge server is listening at port `4242`. This is where RUFF should connect.
|
||||
4. start RUFF `cargo run -- -c defaultconfig.toml`. RUFF will by default look in `~/.config/ruff/config.toml`.
|
||||
This is changed with the `-c` argument to use the local config instead.
|
||||
5. join the `#testing` channel on the IRC server. matterbridge is connected to it.
|
||||
6. type `alec`
|
||||
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
# The nickname to be used
|
||||
nickname = "RUFF"
|
||||
# what matterbridge gateways the bot should work on
|
||||
gateways = ["gateway1"]
|
||||
# the api endpoint the bot should use
|
||||
api = "http://127.0.0.1:4242/api"
|
||||
|
||||
[memes]
|
||||
uffat = "https://jensmemes.tilera.xyz/images/584309714544fbe5961cdb4ddbc880d0/uffat.png"
|
||||
uffgo = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffgothon.png"
|
||||
hey = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/jensgenervt.PNG"
|
||||
uffch = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffch.png"
|
||||
drogen = "https://jensmemes.tilera.xyz/images/000/drogen.PNG"
|
||||
kappa = "https://jensmemes.tilera.xyz/images/d41d8cd98f00b204e9800998ecf8427e/jensKappa%20-%20Kopie%20-%20Kopie.png"
|
||||
hendrik = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/hendrik.png"
|
||||
ufflie = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/ufflie.png"
|
||||
uffns = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffns.png"
|
||||
uffhs = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffhs.png"
|
||||
uffde = "https://jensmemes.tilera.xyz/images/000/uff.png"
|
||||
uffhre = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffhre.png"
|
||||
uffpy = "https://jensmemes.tilera.xyz/images/48f8912f48b94a78b1f0cb939d69a52f/uffpy_ns.png"
|
||||
itbyhf = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/itbyhf.png"
|
||||
tilera = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/tilera.png"
|
||||
lordmzte = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/lord.png"
|
||||
realtox = "https://jensmemes.tilera.xyz/images/6c42dfd93466145a29b97854b394c62c/realtoxguthosting.jpg"
|
||||
jonasled = "https://jensmemes.tilera.xyz/images/584309714544fbe5961cdb4ddbc880d0/jonasled.png"
|
||||
sklave = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/versklavung.png"
|
||||
jens = "https://jensmemes.tilera.xyz/images/48f8912f48b94a78b1f0cb939d69a52f/jens_2.mp4"
|
||||
fresse = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/fresse.jpg"
|
||||
bastard = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/fresse.jpg"
|
||||
uffsr = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffsr.png"
|
||||
party = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/party.mp4"
|
||||
uffrs = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffrs.png"
|
||||
uffjs = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffjs.png"
|
||||
uffkt = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffkt.png"
|
||||
uffj = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffj.png"
|
||||
ufftl = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/ufftl.png"
|
||||
uffhf = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffhf.png"
|
||||
uffmz = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffmz.png"
|
||||
uffal = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffal.png"
|
||||
alec = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/alecmichdochamarsch.png"
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# URL of the homeserver to use
|
||||
homeserver_url = "https://matrix.org"
|
||||
user_id = "@ruffbot:matrix.org"
|
||||
password = "xxx"
|
||||
# path to store databases
|
||||
store_path = "/var/cache/ruff"
|
||||
|
||||
# !sendmeme <id> to make ruff send the meme with the given id
|
||||
sendmeme_command = "!sendmeme"
|
||||
|
||||
# MEMES!!
|
||||
memes = [
|
||||
# random stuff
|
||||
{ keyword = "Xd", id = 1023, match = "contains", match_case = true },
|
||||
{ keyword = "alec", id = 650 },
|
||||
{ keyword = "bastard", id = 375 },
|
||||
{ keyword = "drogen", id = 191 },
|
||||
{ keyword = "fresse", id = 375 },
|
||||
{ keyword = "hendrik", randomcat = "hendrik" },
|
||||
{ keyword = "hey", id = 243 },
|
||||
{ keyword = "hmm", id = 892 },
|
||||
{ keyword = "hmmm", id = 891 },
|
||||
{ keyword = "itbyhf", randomcat = "itbyhf" },
|
||||
{ keyword = "jens", id = 343 },
|
||||
{ keyword = "jonasled", id = 164 },
|
||||
{ keyword = "kappa", id = 182 },
|
||||
{ keyword = "lordmzte", id = 315 },
|
||||
{ keyword = "npm", id = 1080, match = "contains" },
|
||||
{ keyword = "party", id = 619 },
|
||||
{ keyword = "realtox", id = 168 },
|
||||
{ keyword = "sklave", id = 304 },
|
||||
{ keyword = "tilera", id = 316 },
|
||||
{ keyword = "wtf", randomcat = "random", match = "contains" },
|
||||
{ keyword = "wtuff", randomcat = "random", match = "contains" },
|
||||
{ keyword = "xd", id = 1023, match = "contains", match_case = true },
|
||||
|
||||
# üffen
|
||||
{ keyword = "uff", randomcat = "uff" },
|
||||
{ keyword = "biguff", id = 771 },
|
||||
{ keyword = "longuff", id = 771 },
|
||||
{ keyword = "uff2d", id = 1063 },
|
||||
{ keyword = "uffag", id = 1061 },
|
||||
{ keyword = "uffal", id = 654 },
|
||||
{ keyword = "uffat", id = 257 },
|
||||
{ keyword = "uffch", id = 283 },
|
||||
{ keyword = "uffde", id = 144 },
|
||||
{ keyword = "uffgo", id = 568 },
|
||||
{ keyword = "uffhf", id = 645 },
|
||||
{ keyword = "uffhk", id = 693 },
|
||||
{ keyword = "uffhre", id = 312 },
|
||||
{ keyword = "uffhs", id = 331 },
|
||||
{ keyword = "uffj", id = 626 },
|
||||
{ keyword = "uffjl", id = 773 },
|
||||
{ keyword = "uffjs", id = 615 },
|
||||
{ keyword = "uffkt", id = 627 },
|
||||
{ keyword = "ufflie", id = 284 },
|
||||
{ keyword = "uffmj", id = 831 },
|
||||
{ keyword = "uffmz", id = 646 },
|
||||
{ keyword = "uffns", id = 287 },
|
||||
{ keyword = "uffpy", id = 477 },
|
||||
{ keyword = "uffrs", id = 616 },
|
||||
{ keyword = "uffrt", id = 986 },
|
||||
{ keyword = "uffru", id = 999 },
|
||||
{ keyword = "uffsb", id = 818 },
|
||||
{ keyword = "uffso", id = 1006 },
|
||||
{ keyword = "uffsr", id = 585 },
|
||||
{ keyword = "ufftl", id = 644 },
|
||||
{ keyword = "uffwe", id = 779 },
|
||||
]
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=Next gen uff bot
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/ruff -c /etc/ruff/config.toml
|
||||
Environment=RUST_LOG=information
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
53
src/api.rs
53
src/api.rs
|
@ -1,53 +0,0 @@
|
|||
use serde::{
|
||||
de::{Deserializer, Visitor},
|
||||
Deserialize,
|
||||
Serialize,
|
||||
};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Message {
|
||||
pub text: String,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub event: Option<Event>,
|
||||
pub gateway: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct MemeResponse {
|
||||
pub link: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
ApiConnected,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Event {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Event, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct EventVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for EventVisitor {
|
||||
type Value = Event;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("an event type in the form of a string")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
|
||||
Ok(match value {
|
||||
"api_connected" => Event::ApiConnected,
|
||||
_ => Event::Other(value.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(EventVisitor)
|
||||
}
|
||||
}
|
||||
|
190
src/config.rs
190
src/config.rs
|
@ -1,71 +1,157 @@
|
|||
use anyhow::{Context, Result};
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde::{Deserialize, de::{Deserializer, MapAccess, Visitor}};
|
||||
use std::{collections::HashMap, path::PathBuf, marker::PhantomData};
|
||||
use std::fmt;
|
||||
use crate::meme::{Matcher, Meme, MemeIdent};
|
||||
use serde::{
|
||||
de::{self, Deserializer, MapAccess, Visitor},
|
||||
Deserialize,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub nickname: String,
|
||||
pub gateways: Vec<String>,
|
||||
pub api: String,
|
||||
pub memes: LowerCaseMap<String>,
|
||||
pub homeserver_url: Url,
|
||||
pub user_id: String,
|
||||
pub password: String,
|
||||
pub device_name: Option<String>,
|
||||
pub sendmeme_command: Option<String>,
|
||||
pub memes: Vec<Meme>,
|
||||
pub store_path: PathBuf,
|
||||
#[serde(default = "default_clear_threshold")]
|
||||
pub clear_cache_threshold: u32,
|
||||
}
|
||||
|
||||
static CONFIG: OnceCell<Config> = OnceCell::new();
|
||||
pub static CONFIG_PATH: OnceCell<PathBuf> = OnceCell::new();
|
||||
|
||||
pub async fn try_get_config<'a>() -> Result<&'a Config> {
|
||||
match CONFIG.get() {
|
||||
None => {
|
||||
log::info!("Initializing config");
|
||||
let config = CONFIG_PATH.get_or_try_init::<_, anyhow::Error>(|| {
|
||||
let mut config = dirs::config_dir().context("Failed to get config dir")?;
|
||||
config.push("ruff/config.toml");
|
||||
Ok(config)
|
||||
})?;
|
||||
|
||||
if let Some(p) = config.to_str() {
|
||||
log::info!("trying to read config at {}", p);
|
||||
}
|
||||
|
||||
let bytes = tokio::fs::read(config).await?;
|
||||
let config = toml::from_slice::<Config>(&bytes)?;
|
||||
Ok(CONFIG.get_or_init(|| config))
|
||||
},
|
||||
Some(c) => Ok(c),
|
||||
}
|
||||
fn default_clear_threshold() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
pub struct LowerCaseMap<T> {
|
||||
pub map: HashMap<String, T>
|
||||
}
|
||||
|
||||
impl<'de, T: Deserialize<'de>> Deserialize<'de> for LowerCaseMap<T> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<LowerCaseMap<T>, D::Error>
|
||||
impl<'de> Deserialize<'de> for Meme {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct LowerCaseMapVisitor<T>(PhantomData<T>);
|
||||
const FIELDS: &[&str] = &["keyword", "id", "randomcat"];
|
||||
enum Field {
|
||||
Keyword,
|
||||
Ident(IdentField),
|
||||
Matcher,
|
||||
MatchCase,
|
||||
}
|
||||
|
||||
impl<'de, T: Deserialize<'de>> Visitor<'de> for LowerCaseMapVisitor<T> {
|
||||
type Value = LowerCaseMap<T>;
|
||||
enum IdentField {
|
||||
RandomCat,
|
||||
Id,
|
||||
}
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a key-value pair with a string key")
|
||||
}
|
||||
impl<'de> Deserialize<'de> for Field {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct Vis;
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
|
||||
let mut map = HashMap::new();
|
||||
while let Some((k, v)) = access.next_entry::<String, T>()? {
|
||||
map.insert(k.to_lowercase(), v);
|
||||
impl<'de> Visitor<'de> for Vis {
|
||||
type Value = Field;
|
||||
fn expecting(
|
||||
&self,
|
||||
fmt: &mut std::fmt::Formatter<'_>,
|
||||
) -> Result<(), std::fmt::Error> {
|
||||
fmt.write_str("a field for a meme")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
Ok(match v {
|
||||
"keyword" => Field::Keyword,
|
||||
"randomcat" => Field::Ident(IdentField::RandomCat),
|
||||
"id" => Field::Ident(IdentField::Id),
|
||||
"match" => Field::Matcher,
|
||||
"match_case" => Field::MatchCase,
|
||||
_ => return Err(de::Error::unknown_field(v, FIELDS)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LowerCaseMap { map })
|
||||
deserializer.deserialize_identifier(Vis)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(LowerCaseMapVisitor(PhantomData))
|
||||
struct Vis;
|
||||
|
||||
impl<'de> Visitor<'de> for Vis {
|
||||
type Value = Meme;
|
||||
fn expecting(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
fmt.write_str("a meme")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
let mut keyword = None;
|
||||
let mut ident = None;
|
||||
let mut matcher = None;
|
||||
let mut match_case = None;
|
||||
|
||||
while let Some(key) = map.next_key()? {
|
||||
match key {
|
||||
Field::Keyword => {
|
||||
if keyword.is_some() {
|
||||
return Err(de::Error::duplicate_field("keyword"));
|
||||
}
|
||||
|
||||
keyword = Some(map.next_value()?);
|
||||
},
|
||||
|
||||
Field::Ident(i) => {
|
||||
if ident.is_some() {
|
||||
return Err(de::Error::duplicate_field(
|
||||
"ident, can only have one.",
|
||||
));
|
||||
}
|
||||
|
||||
match i {
|
||||
IdentField::Id => {
|
||||
ident = Some(MemeIdent::Id(map.next_value()?));
|
||||
},
|
||||
IdentField::RandomCat => {
|
||||
ident = Some(MemeIdent::RandomCat(map.next_value()?));
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
Field::Matcher => {
|
||||
if matcher.is_some() {
|
||||
return Err(de::Error::duplicate_field("match"));
|
||||
}
|
||||
|
||||
matcher = Some(map.next_value()?);
|
||||
},
|
||||
|
||||
Field::MatchCase => {
|
||||
if match_case.is_some() {
|
||||
return Err(de::Error::duplicate_field("match"));
|
||||
}
|
||||
|
||||
match_case = Some(map.next_value()?);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let keyword = keyword.ok_or_else(|| de::Error::missing_field("keyword"))?;
|
||||
let ident = ident.ok_or_else(|| de::Error::missing_field("ident"))?;
|
||||
let matcher = matcher.unwrap_or(Matcher::Begins);
|
||||
let match_case = match_case.unwrap_or(false);
|
||||
|
||||
Ok(Meme {
|
||||
keyword,
|
||||
ident,
|
||||
matcher,
|
||||
match_case,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_struct("Meme", FIELDS, Vis)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
461
src/main.rs
461
src/main.rs
|
@ -1,267 +1,276 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use api::{Event, Message, MemeResponse};
|
||||
use bytes::Bytes;
|
||||
use pin_project_lite::pin_project;
|
||||
use reqwest::Client;
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use libjens::JMClient;
|
||||
use log::{error, info, warn};
|
||||
use matrix_sdk::{
|
||||
config::SyncSettings,
|
||||
deserialized_responses::SyncResponse,
|
||||
encryption::verification::Verification,
|
||||
room::Room,
|
||||
ruma::{
|
||||
api::client::r0::{
|
||||
session::login,
|
||||
uiaa::{AuthData, Password, UserIdentifier},
|
||||
},
|
||||
assign,
|
||||
events::{
|
||||
room::{
|
||||
member::StrippedRoomMemberEvent,
|
||||
message::{
|
||||
MessageType,
|
||||
RoomMessageEventContent,
|
||||
SyncRoomMessageEvent,
|
||||
TextMessageEventContent,
|
||||
},
|
||||
},
|
||||
AnyToDeviceEvent,
|
||||
SyncMessageEvent,
|
||||
},
|
||||
UserId,
|
||||
},
|
||||
Client,
|
||||
LoopCtrl,
|
||||
};
|
||||
use rand::{rngs::StdRng, SeedableRng};
|
||||
use sled::Db;
|
||||
use std::{
|
||||
pin::Pin,
|
||||
string::FromUtf8Error,
|
||||
task::{Context, Poll},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU32},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use thiserror::Error;
|
||||
use tokio::stream::{Stream, StreamExt};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
use config::Config;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
#[structopt(about = "The next generation uffbot for matterbridge!")]
|
||||
pub struct Opt {
|
||||
mod config;
|
||||
mod meme;
|
||||
mod responder;
|
||||
mod util;
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(
|
||||
long,
|
||||
short,
|
||||
help = "Use the given config file instead of the default. (located at \
|
||||
.config/ruff/config.toml)"
|
||||
long,
|
||||
help = "config file to use",
|
||||
default_value = "~/.config/ruff/config.toml"
|
||||
)]
|
||||
config: Option<String>,
|
||||
config: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
fern::Dispatch::new()
|
||||
.format(|out, message, record| {
|
||||
out.finish(format_args!(
|
||||
"{}[{}][{}] {}",
|
||||
chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"),
|
||||
record.target(),
|
||||
record.level(),
|
||||
message
|
||||
))
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
let opt = Opt::from_args();
|
||||
let config = std::fs::read(&opt.config).map_err(|e| anyhow!("Error reading config: {}", e))?;
|
||||
let config =
|
||||
toml::from_slice::<Config>(&config).map_err(|e| anyhow!("Error parsing config: {}", e))?;
|
||||
let config = Arc::new(config);
|
||||
|
||||
let client = Client::new(config.homeserver_url.clone())?;
|
||||
|
||||
let device_name = config.device_name.as_ref().map(String::as_ref);
|
||||
|
||||
let bot = Arc::new(Bot {
|
||||
client: client.clone(),
|
||||
jm_client: RwLock::new(JMClient::new()),
|
||||
memecache: sled::open(config.store_path.join("memecache"))
|
||||
.map_err(|e| anyhow!("error opening memecache: {}", e))?,
|
||||
config: Arc::clone(&config),
|
||||
meme_count: AtomicU32::new(0),
|
||||
rng: Mutex::new(StdRng::from_rng(rand::thread_rng())?),
|
||||
});
|
||||
|
||||
client
|
||||
.register_event_handler(on_stripped_state_member)
|
||||
.await;
|
||||
let bot_ = Arc::clone(&bot);
|
||||
client
|
||||
.register_event_handler(move |ev, client, room| {
|
||||
on_room_message(ev, client, room, Arc::clone(&bot_))
|
||||
})
|
||||
.level(log::LevelFilter::Debug)
|
||||
// hyper is quite spammy
|
||||
.level_for("hyper", log::LevelFilter::Info)
|
||||
.chain(std::io::stdout())
|
||||
.apply()?;
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let opt = Opt::from_args_safe()?;
|
||||
let login::Response { user_id, .. } = client
|
||||
.login(&config.user_id, &config.password, device_name, Some("ruff"))
|
||||
.await?;
|
||||
|
||||
if let Some(c) = opt.config {
|
||||
let _ = config::CONFIG_PATH.set(c.into());
|
||||
let initial = AtomicBool::from(true);
|
||||
let initial_ref = &initial;
|
||||
let client_ref = &client;
|
||||
let config_ref = &config;
|
||||
let user_id_ref = &user_id;
|
||||
|
||||
client
|
||||
.sync_with_callback(SyncSettings::new(), |response| async move {
|
||||
if let Err(e) = on_response(&response, client_ref).await {
|
||||
error!("Error processing response: {}", e);
|
||||
}
|
||||
|
||||
let initial = initial_ref;
|
||||
|
||||
if initial.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
if let Err(e) =
|
||||
on_initial_response(client_ref, user_id_ref, &config_ref.password).await
|
||||
{
|
||||
error!("Error processing initial response: {}", e);
|
||||
}
|
||||
|
||||
initial.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
LoopCtrl::Continue
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Bot {
|
||||
client: Client,
|
||||
jm_client: RwLock<JMClient>,
|
||||
memecache: Db,
|
||||
config: Arc<Config>,
|
||||
/// used to keep track of how many memes have been sent.
|
||||
/// this is reset once the threshold set in the config has been reached, and
|
||||
/// the JMClient cache is cleared.
|
||||
meme_count: AtomicU32,
|
||||
rng: Mutex<StdRng>,
|
||||
}
|
||||
|
||||
async fn on_stripped_state_member(event: StrippedRoomMemberEvent, client: Client, room: Room) {
|
||||
if event.state_key == client.user_id().await.unwrap() {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut stream = stream(&client).await?;
|
||||
if let Room::Invited(room) = room {
|
||||
info!("Autojoining room {}", room.room_id());
|
||||
let mut delay = 2;
|
||||
|
||||
while let Some(msg) = stream.next().await {
|
||||
if let Err(e) = next_message(&client, msg).await {
|
||||
log::error!("Got error processing message: {}", e);
|
||||
while let Err(err) = client.join_room_by_id(room.room_id()).await {
|
||||
// retry autojoin due to synapse sending invites, before the
|
||||
// invited user can join for more information see
|
||||
// https://github.com/matrix-org/synapse/issues/4345
|
||||
warn!(
|
||||
"Failed to join room {} ({:?}), retrying in {}s",
|
||||
room.room_id(),
|
||||
err,
|
||||
delay
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||
delay *= 2;
|
||||
|
||||
if delay > 3600 {
|
||||
error!("Can't join room {} ({:?})", room.room_id(), err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log::error!("Stream to server closed. restarting.");
|
||||
info!("Successfully joined room {}", room.room_id());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum MessageProcessError {
|
||||
#[error("Got error `{error}` trying to deserialize message:\n{data}")]
|
||||
Deserialize {
|
||||
error: serde_json::Error,
|
||||
data: String,
|
||||
},
|
||||
async fn on_room_message(msg: SyncRoomMessageEvent, client: Client, room: Room, bot: Arc<Bot>) {
|
||||
if client
|
||||
.user_id()
|
||||
.await
|
||||
.map(|u| u == msg.sender)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
#[error("Got Error `{error}` try to deserialize invalid UTF-8 data:\n{data:X?}")]
|
||||
InvalidUtf8 { error: FromUtf8Error, data: Vec<u8> },
|
||||
if let SyncMessageEvent {
|
||||
content:
|
||||
RoomMessageEventContent {
|
||||
msgtype: MessageType::Text(TextMessageEventContent { body: msg_body, .. }),
|
||||
..
|
||||
},
|
||||
..
|
||||
} = msg
|
||||
{
|
||||
if let Err(e) = responder::on_msg(&msg_body, room, &bot).await {
|
||||
error!("Responder error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_message(client: &Client, raw_msg: Vec<u8>) -> Result<()> {
|
||||
let s_data =
|
||||
String::from_utf8(raw_msg.clone()).map_err(|error| MessageProcessError::InvalidUtf8 {
|
||||
error,
|
||||
data: raw_msg,
|
||||
})?;
|
||||
let message =
|
||||
serde_json::from_str(&s_data).map_err(|error| MessageProcessError::Deserialize {
|
||||
error,
|
||||
data: s_data,
|
||||
})?;
|
||||
async fn on_initial_response(
|
||||
client: &Client,
|
||||
user_id: &UserId,
|
||||
password: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
bootstrap_cross_signing(client, user_id, password).await?;
|
||||
|
||||
log::info!("Processing message {:?}", &message);
|
||||
process_message(client, message).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_message(client: &Client, message: Message) -> Result<()> {
|
||||
let config = config::try_get_config().await?;
|
||||
match message {
|
||||
Message {
|
||||
event: Some(Event::ApiConnected),
|
||||
..
|
||||
} => log::info!("got api connected event"),
|
||||
Message { text, gateway, .. } if config.gateways.contains(&gateway) && !text.is_empty() => {
|
||||
if let Some(start) = text.split(" ").next() {
|
||||
let lower = start.to_lowercase();
|
||||
if lower == "uff" {
|
||||
// TODO this is temporary, once JM3.0 is out, we will request uff memes at
|
||||
// startup and take a random one when needed, so we don't make a request each
|
||||
// time (which slows the bot down significantly)
|
||||
let res = client.get("https://data.tilera.xyz/api/jensmemes/random?category=uff").send().await?.text().await?;
|
||||
let MemeResponse { link } = serde_json::from_str(&res)?;
|
||||
|
||||
let message = Message {
|
||||
text: link,
|
||||
gateway,
|
||||
username: config.nickname.clone(),
|
||||
event: None,
|
||||
};
|
||||
|
||||
send_message(client, &message).await?;
|
||||
|
||||
return Ok(());
|
||||
async fn on_response(response: &SyncResponse, client: &Client) -> anyhow::Result<()> {
|
||||
for event in response
|
||||
.to_device
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| e.deserialize().ok())
|
||||
{
|
||||
match event {
|
||||
AnyToDeviceEvent::KeyVerificationStart(e) => {
|
||||
info!("Starting verification");
|
||||
if let Some(Verification::SasV1(sas)) = &client
|
||||
.get_verification(&e.sender, &e.content.transaction_id)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = sas.accept().await {
|
||||
error!("Error accepting key verification request: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(meme) = config.memes.map.get(&lower) {
|
||||
log::info!(
|
||||
r#"found meme matching message "{}". responding with "{}""#,
|
||||
text,
|
||||
&meme
|
||||
);
|
||||
},
|
||||
|
||||
let message = Message {
|
||||
text: meme.clone(),
|
||||
gateway,
|
||||
username: config.nickname.clone(),
|
||||
event: None,
|
||||
};
|
||||
|
||||
send_message(client, &message).await?;
|
||||
AnyToDeviceEvent::KeyVerificationKey(e) => {
|
||||
if let Some(Verification::SasV1(sas)) = &client
|
||||
.get_verification(&e.sender, &e.content.transaction_id)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = sas.confirm().await {
|
||||
error!("Error confirming key verification request: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
msg => log::warn!("Got unknown message: {:?}", msg),
|
||||
},
|
||||
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(client: &Client, msg: &Message) -> Result<()> {
|
||||
let config = config::try_get_config().await?;
|
||||
log::info!("Sending message: {:?}", msg);
|
||||
let res: Result<()> = {
|
||||
let response = client
|
||||
.post(&format!("{}/message", config.api))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_vec(&msg)?)
|
||||
.send()
|
||||
.await?;
|
||||
async fn bootstrap_cross_signing(
|
||||
client: &Client,
|
||||
user_id: &UserId,
|
||||
password: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
info!("bootstrapping e2e");
|
||||
if let Err(e) = client.bootstrap_cross_signing(None).await {
|
||||
if let Some(response) = e.uiaa_response() {
|
||||
let auth_data = AuthData::Password(assign!(
|
||||
Password::new(UserIdentifier::MatrixId(user_id.as_str()), password),
|
||||
{ session: response.session.as_deref() }
|
||||
));
|
||||
|
||||
log::info!(
|
||||
"sent message. server responded with `{}`",
|
||||
response.text().await?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
res.map_err(|e| anyhow!("Error sending message:\n{}", e))
|
||||
}
|
||||
|
||||
async fn stream(client: &Client) -> Result<impl Stream<Item = Vec<u8>>> {
|
||||
let conf = config::try_get_config().await?;
|
||||
let stream = client
|
||||
.get(&format!("{}/stream", conf.api))
|
||||
.send()
|
||||
.await?
|
||||
.bytes_stream();
|
||||
Ok(NewlineStream::new(stream.filter_map(Result::ok)))
|
||||
}
|
||||
|
||||
pin_project! {
|
||||
struct NewlineStream<T: Stream<Item = Bytes>> {
|
||||
#[pin]
|
||||
inner: T,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Stream<Item = Bytes>> Stream for NewlineStream<T> {
|
||||
type Item = Vec<u8>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
loop {
|
||||
let poll = self.as_mut().project().inner.poll_next(cx);
|
||||
// if the inner stream is not done yet, we are not either
|
||||
let poll = match poll {
|
||||
Poll::Ready(x) => x,
|
||||
Poll::Pending => return Poll::Pending,
|
||||
};
|
||||
|
||||
match poll {
|
||||
Some(i) => {
|
||||
let buf = &mut self.as_mut().project().buf;
|
||||
buf.extend(i.into_iter());
|
||||
|
||||
let pos = match buf.iter().position(|&e| e == b'\n') {
|
||||
Some(n) => n,
|
||||
// if there is no newline yet, try again
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let result = Vec::from(&buf[0..pos]);
|
||||
**buf = Vec::from(&buf[1 + pos..]);
|
||||
|
||||
return Poll::Ready(Some(result));
|
||||
},
|
||||
// if the inner stream had nothing, we return the buffer and are done
|
||||
// in order to avoid an inifite loop when the inner stream is done, we clear the
|
||||
// buffer and return None once the buffer has been output once
|
||||
None => {
|
||||
if !self.buf.is_empty() {
|
||||
let buf = self.buf.clone();
|
||||
*self.as_mut().project().buf = vec![];
|
||||
return Poll::Ready(Some(buf));
|
||||
} else {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
},
|
||||
}
|
||||
client
|
||||
.bootstrap_cross_signing(Some(auth_data))
|
||||
.await
|
||||
.context("Couldn't bootstrap cross signing")?;
|
||||
} else {
|
||||
bail!("Error during cross-signing bootstrap {:#?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("bootstrapped e2e");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<T: Stream<Item = Bytes>> NewlineStream<T> {
|
||||
pub fn new(stream: T) -> Self {
|
||||
Self {
|
||||
inner: stream,
|
||||
buf: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
use tokio::stream::StreamExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn newline_stream() {
|
||||
let stream = tokio::stream::iter(
|
||||
vec![
|
||||
Bytes::from("hello "),
|
||||
Bytes::from("world"),
|
||||
Bytes::from("\nfoobar"),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
let mut newline_stream = NewlineStream::new(stream);
|
||||
|
||||
assert_eq!(newline_stream.next().await, Some(b"hello world".to_vec()));
|
||||
assert_eq!(newline_stream.next().await, Some(b"foobar".to_vec()));
|
||||
assert_eq!(newline_stream.next().await, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
use crate::Bot;
|
||||
use log::error;
|
||||
use rand::seq::IteratorRandom;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const ALLOWED_SPACES: &str = " ,.;:!?({-_";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Meme {
|
||||
pub keyword: String,
|
||||
pub ident: MemeIdent,
|
||||
pub matcher: Matcher,
|
||||
pub match_case: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Matcher {
|
||||
Begins,
|
||||
Contains,
|
||||
}
|
||||
|
||||
impl Meme {
|
||||
/// checks if the meme should be triggered for the given message
|
||||
pub fn matches(&self, msg: &str) -> bool {
|
||||
let mut msg = msg.to_string();
|
||||
let mut keyword = self.keyword.clone();
|
||||
|
||||
if !self.match_case {
|
||||
msg = msg.to_ascii_lowercase();
|
||||
keyword = keyword.to_ascii_lowercase();
|
||||
}
|
||||
|
||||
match self.matcher {
|
||||
Matcher::Begins => {
|
||||
msg.starts_with(&keyword) &&
|
||||
// msg must have one of allowed chars after keyword
|
||||
msg.chars().nth(keyword.len()).map(|c| ALLOWED_SPACES.contains(c)).unwrap_or(true)
|
||||
},
|
||||
|
||||
Matcher::Contains => msg
|
||||
.match_indices(&keyword)
|
||||
.map(|(idx, subs)| {
|
||||
(idx == 0 ||
|
||||
msg.chars()
|
||||
.nth(idx - 1)
|
||||
.map(|c| ALLOWED_SPACES.contains(c))
|
||||
.unwrap_or(true)) &&
|
||||
msg.chars()
|
||||
.nth(idx + subs.len())
|
||||
.map(|c| ALLOWED_SPACES.contains(c))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.any(|b| b),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_id(&self, bot: &Bot) -> anyhow::Result<Option<u32>> {
|
||||
let memes = bot.jm_client.read().await.get_memes().await?;
|
||||
match &self.ident {
|
||||
MemeIdent::Id(i) => Ok(Some(*i)),
|
||||
MemeIdent::RandomCat(c) => {
|
||||
let meme = memes
|
||||
.iter()
|
||||
.filter(|m| &m.category == c)
|
||||
.choose(&mut *bot.rng.lock().await);
|
||||
|
||||
if let Some(meme) = meme {
|
||||
let id = meme.id.parse::<u32>();
|
||||
match id {
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Error parsing meme ID {} for meme {:?} thanks to tilera's PHP api",
|
||||
&meme.id, &meme
|
||||
);
|
||||
Err(e.into())
|
||||
},
|
||||
Ok(id) => Ok(Some(id)),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MemeIdent {
|
||||
RandomCat(String),
|
||||
Id(u32),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_begins_test() {
|
||||
let meme = Meme {
|
||||
keyword: String::from("test"),
|
||||
ident: MemeIdent::Id(42),
|
||||
matcher: Matcher::Begins,
|
||||
match_case: false,
|
||||
};
|
||||
|
||||
assert!(!meme.matches("xxx"));
|
||||
assert!(!meme.matches("testxxx"));
|
||||
assert!(!meme.matches("xxxtestxxx"));
|
||||
assert!(!meme.matches("xxxtest xxx"));
|
||||
assert!(!meme.matches("xxx testxxx"));
|
||||
assert!(meme.matches("test"));
|
||||
assert!(meme.matches("test xxx"));
|
||||
assert!(meme.matches("test; xxx"));
|
||||
assert!(meme.matches("test;xxx"));
|
||||
assert!(meme.matches("TEST"));
|
||||
assert!(meme.matches("TeSt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_contains_test() {
|
||||
let meme = Meme {
|
||||
keyword: String::from("test"),
|
||||
ident: MemeIdent::Id(42),
|
||||
matcher: Matcher::Contains,
|
||||
match_case: false,
|
||||
};
|
||||
|
||||
assert!(!meme.matches("xxx"));
|
||||
assert!(!meme.matches("xxxtestxxx"));
|
||||
assert!(!meme.matches("xxxtest xxx"));
|
||||
assert!(!meme.matches("xxx testxxx"));
|
||||
assert!(!meme.matches("xxxtest"));
|
||||
assert!(!meme.matches("testxxx"));
|
||||
assert!(meme.matches("xxx test xxx"));
|
||||
assert!(meme.matches("xxx,test.xxx"));
|
||||
assert!(meme.matches("xxx,TEST.xxx"));
|
||||
assert!(meme.matches("xxx,TeSt.xxx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_case_test() {
|
||||
let meme = Meme {
|
||||
keyword: String::from("TeSt"),
|
||||
ident: MemeIdent::Id(42),
|
||||
matcher: Matcher::Contains,
|
||||
match_case: true,
|
||||
};
|
||||
|
||||
assert!(!meme.matches("test"));
|
||||
assert!(meme.matches("TeSt"));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
use crate::{util, Bot};
|
||||
use anyhow::Context;
|
||||
use log::{error, info, warn};
|
||||
use matrix_sdk::{
|
||||
room::{Joined, Room},
|
||||
ruma::{
|
||||
api::client::r0::media::create_content,
|
||||
events::room::{
|
||||
message::{
|
||||
FileInfo,
|
||||
FileMessageEventContent,
|
||||
ImageMessageEventContent,
|
||||
MessageType,
|
||||
RoomMessageEventContent,
|
||||
VideoInfo,
|
||||
VideoMessageEventContent,
|
||||
},
|
||||
ImageInfo,
|
||||
},
|
||||
MxcUri,
|
||||
UInt,
|
||||
},
|
||||
};
|
||||
use mime::Mime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{io::Cursor, sync::atomic::Ordering};
|
||||
|
||||
/// A meme stored in the cache database
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct CachedMeme {
|
||||
/// mxc url of the meme
|
||||
mxc: Box<MxcUri>,
|
||||
/// MIME type of the meme
|
||||
#[serde(with = "util::mime_serialize")]
|
||||
mime: Mime,
|
||||
/// file size of the meme
|
||||
size: UInt,
|
||||
/// file name of the meme
|
||||
meme_name: String,
|
||||
}
|
||||
|
||||
impl CachedMeme {
|
||||
fn into_message_type(self) -> MessageType {
|
||||
let Self {
|
||||
mxc,
|
||||
mime,
|
||||
size,
|
||||
meme_name,
|
||||
} = self;
|
||||
|
||||
match mime.type_() {
|
||||
mime::IMAGE => MessageType::Image(ImageMessageEventContent::plain(
|
||||
meme_name,
|
||||
mxc,
|
||||
Some(Box::new({
|
||||
let mut info = ImageInfo::new();
|
||||
info.mimetype = Some(mime.to_string());
|
||||
info.size = Some(size);
|
||||
info
|
||||
})),
|
||||
)),
|
||||
mime::VIDEO => MessageType::Video(VideoMessageEventContent::plain(
|
||||
meme_name,
|
||||
mxc,
|
||||
Some(Box::new({
|
||||
let mut info = VideoInfo::new();
|
||||
info.mimetype = Some(mime.to_string());
|
||||
info.size = Some(size);
|
||||
info
|
||||
})),
|
||||
)),
|
||||
_ => MessageType::File(FileMessageEventContent::plain(
|
||||
meme_name,
|
||||
mxc,
|
||||
Some(Box::new({
|
||||
let mut info = FileInfo::new();
|
||||
info.mimetype = Some(mime.to_string());
|
||||
info.size = Some(size);
|
||||
info
|
||||
})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn on_msg(msg: &str, room: Room, bot: &Bot) -> anyhow::Result<()> {
|
||||
let room = match room {
|
||||
Room::Joined(room) => room,
|
||||
_ => {
|
||||
warn!(
|
||||
"Received message '{}' in room {:?} that's not joined",
|
||||
msg,
|
||||
room.name()
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(ref sendmeme_cmd) = bot.config.sendmeme_command {
|
||||
let mut words = msg.split(' ');
|
||||
if words.next() == Some(sendmeme_cmd) {
|
||||
if let Some(id) = words.next() {
|
||||
let id = if let Ok(id) = id.parse::<u32>() {
|
||||
id
|
||||
} else {
|
||||
room.send(RoomMessageEventContent::text_plain("Invalid ID!"), None)
|
||||
.await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
bot.jm_client.write().await.clear_cache().await;
|
||||
bot.meme_count.store(0, Ordering::SeqCst);
|
||||
cache_send_meme(id, bot, room).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for meme in &bot.config.memes {
|
||||
if meme.matches(msg) {
|
||||
if let Some(id) = meme.get_id(bot).await? {
|
||||
cache_send_meme(id, bot, room).await?;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cache_send_meme(meme_id: u32, bot: &Bot, room: Joined) -> anyhow::Result<()> {
|
||||
bot.meme_count.fetch_add(1, Ordering::SeqCst);
|
||||
let memes = bot.jm_client.read().await.get_memes().await?;
|
||||
let meme = memes
|
||||
.iter()
|
||||
.find(|m| m.id.parse::<u32>().ok() == Some(meme_id))
|
||||
.cloned();
|
||||
|
||||
if let Some(meme) = meme {
|
||||
if let Some(ivec) = bot.memecache.get(meme_id.to_be_bytes())? {
|
||||
let cached = bincode::deserialize::<CachedMeme>(&ivec)?;
|
||||
send_meme(&room, cached).await?;
|
||||
} else {
|
||||
info!("Meme {} not found in cache, uploading...", meme_id);
|
||||
let resp = bot
|
||||
.jm_client
|
||||
.read()
|
||||
.await
|
||||
.http
|
||||
.get(&meme.link)
|
||||
.send()
|
||||
.await
|
||||
.context("error downloading meme")?;
|
||||
let resp = resp.bytes().await?;
|
||||
|
||||
if let Some(mime) = mime_guess::from_path(&meme.link).first() {
|
||||
let size = resp.len();
|
||||
let create_content::Response { content_uri, .. } =
|
||||
bot.client.upload(&mime, &mut Cursor::new(resp)).await?;
|
||||
|
||||
let cached = CachedMeme {
|
||||
mxc: content_uri,
|
||||
mime,
|
||||
size: UInt::new(size as u64)
|
||||
.context("Meme has file size over allowed limit!")?,
|
||||
meme_name: meme
|
||||
.link
|
||||
.split('/')
|
||||
.last()
|
||||
.unwrap_or(&meme.link)
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
bot.memecache
|
||||
.insert(meme_id.to_be_bytes(), bincode::serialize(&cached)?)?;
|
||||
|
||||
send_meme(&room, cached).await?;
|
||||
// we do this after we have responded, in order to not delay the response
|
||||
if bot.meme_count.load(Ordering::SeqCst) >= bot.config.clear_cache_threshold {
|
||||
let mut client = bot.jm_client.write().await;
|
||||
bot.meme_count.store(0, Ordering::SeqCst);
|
||||
client.clear_cache().await;
|
||||
// memes requested but not used, but they will be cached
|
||||
client.get_memes().await?;
|
||||
}
|
||||
} else {
|
||||
error!(
|
||||
"Couldn't guess MIME type of meme '{}', skipping.",
|
||||
&meme.link
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
room.send(
|
||||
RoomMessageEventContent::text_plain(format!("No meme with id '{}'", meme_id)),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_meme(room: &Joined, cached: CachedMeme) -> anyhow::Result<()> {
|
||||
let msg_ty = cached.into_message_type();
|
||||
room.send(RoomMessageEventContent::new(msg_ty), None)
|
||||
.await
|
||||
.context("Failed to send meme")?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
pub mod mime_serialize {
|
||||
use mime::Mime;
|
||||
use serde::{
|
||||
de::{self, Unexpected, Visitor},
|
||||
Deserializer,
|
||||
Serializer,
|
||||
};
|
||||
|
||||
pub fn serialize<S>(data: &Mime, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(data.as_ref())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Mime, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct Vis;
|
||||
impl<'de> Visitor<'de> for Vis {
|
||||
type Value = Mime;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a MIME type")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
v.parse()
|
||||
.map_err(|_| de::Error::invalid_value(Unexpected::Str(v), &Vis))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(Vis)
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
[irc.testirc]
|
||||
# "irc" is the hostname of the irc container in docker-compose
|
||||
Server = "irc:6667"
|
||||
Nick = "bridge"
|
||||
RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "
|
||||
|
||||
[[gateway]]
|
||||
name = "gateway1"
|
||||
enable = true
|
||||
|
||||
[[gateway.inout]]
|
||||
account = "irc.testirc"
|
||||
channel = "#testing"
|
||||
|
||||
# api config
|
||||
[api.ruff]
|
||||
BindAddress = "0.0.0.0:4242"
|
||||
Buffer = 1000
|
||||
RemoteNickFormat = "{NICK}"
|
||||
|
||||
[[gateway.inout]]
|
||||
account = "api.ruff"
|
||||
channel = "ruffapi"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
version: "3.3"
|
||||
services:
|
||||
matterbridge:
|
||||
image: "42wim/matterbridge"
|
||||
restart: "unless-stopped"
|
||||
depends_on:
|
||||
- "irc"
|
||||
volumes:
|
||||
- "./data/matterbridge:/etc/matterbridge"
|
||||
ports:
|
||||
- "4242:4242"
|
||||
|
||||
irc:
|
||||
image: "inspircd/inspircd-docker"
|
||||
ports:
|
||||
- "6667:6667"
|
||||
|
Loading…
Reference in New Issue