init
This commit is contained in:
commit
877f797926
9 changed files with 466 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
|
40
Cargo.toml
Normal file
40
Cargo.toml
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
[package]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.1.0"
|
||||||
|
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"
|
||||||
|
toml = "0.5.8"
|
||||||
|
|
||||||
|
[dependencies.tokio]
|
||||||
|
version = "0.2"
|
||||||
|
features = [
|
||||||
|
"macros",
|
||||||
|
"fs",
|
||||||
|
"stream",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies.serde]
|
||||||
|
version = "1.0"
|
||||||
|
features = ["derive"]
|
||||||
|
|
||||||
|
[dependencies.reqwest]
|
||||||
|
version = "0.10"
|
||||||
|
features = ["stream"]
|
||||||
|
|
42
defaultconfig.toml
Normal file
42
defaultconfig.toml
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# 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://localhost: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"
|
||||||
|
|
13
rustfmt.toml
Normal file
13
rustfmt.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
unstable_features = true
|
||||||
|
binop_separator = "Back"
|
||||||
|
format_code_in_doc_comments = true
|
||||||
|
format_macro_matchers = true
|
||||||
|
format_strings = true
|
||||||
|
imports_layout = "HorizontalVertical"
|
||||||
|
match_block_trailing_comma = true
|
||||||
|
merge_imports = true
|
||||||
|
normalize_comments = true
|
||||||
|
use_field_init_shorthand = true
|
||||||
|
use_try_shorthand = true
|
||||||
|
wrap_comments = true
|
||||||
|
|
47
src/api.rs
Normal file
47
src/api.rs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
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(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)
|
||||||
|
}
|
||||||
|
}
|
37
src/config.rs
Normal file
37
src/config.rs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub nickname: String,
|
||||||
|
pub gateways: Vec<String>,
|
||||||
|
pub api: String,
|
||||||
|
pub memes: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
245
src/main.rs
Normal file
245
src/main.rs
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use api::{Event, Message};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use pin_project_lite::pin_project;
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::{
|
||||||
|
pin::Pin,
|
||||||
|
string::FromUtf8Error,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
use structopt::StructOpt;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::stream::{Stream, StreamExt};
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
#[structopt(about = "The next generation uffbot for matterbridge!")]
|
||||||
|
pub struct Opt {
|
||||||
|
#[structopt(
|
||||||
|
long,
|
||||||
|
short,
|
||||||
|
help = "Use the given config file instead of the default. (located at \
|
||||||
|
.config/ruff/config.toml)"
|
||||||
|
)]
|
||||||
|
config: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.level(log::LevelFilter::Debug)
|
||||||
|
.chain(std::io::stdout())
|
||||||
|
.apply()?;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let opt = Opt::from_args_safe()?;
|
||||||
|
|
||||||
|
if let Some(c) = opt.config {
|
||||||
|
let _ = config::CONFIG_PATH.set(c.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut stream = stream(&client).await?;
|
||||||
|
|
||||||
|
while let Some(msg) = stream.next().await {
|
||||||
|
if let Err(e) = next_message(&client, msg).await {
|
||||||
|
log::error!("Got error processing message: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::error!("Stream to server closed. restarting.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
enum MessageProcessError {
|
||||||
|
#[error("Got error `{error}` trying to deserialize message:\n{data}")]
|
||||||
|
Deserialize {
|
||||||
|
error: serde_json::Error,
|
||||||
|
data: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Got Error `{error}` try to deserialize invalid UTF-8 data:\n{data:X?}")]
|
||||||
|
InvalidUtf8 { error: FromUtf8Error, data: Vec<u8> },
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!("Processing message {:?}", &message);
|
||||||
|
process_message(client, message).await
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if let Some(meme) = config.memes.get(&start.to_lowercase()) {
|
||||||
|
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?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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?;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
24
testenv/data/matterbridge/matterbridge.toml
Normal file
24
testenv/data/matterbridge/matterbridge.toml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
[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"
|
||||||
|
|
15
testenv/docker-compose.yml
Normal file
15
testenv/docker-compose.yml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
version: "3.3"
|
||||||
|
services:
|
||||||
|
matterbridge:
|
||||||
|
image: "42wim/matterbridge"
|
||||||
|
restart: "unless-stopped"
|
||||||
|
volumes:
|
||||||
|
- "./data/matterbridge:/etc/matterbridge"
|
||||||
|
ports:
|
||||||
|
- "4242:4242"
|
||||||
|
|
||||||
|
irc:
|
||||||
|
image: "inspircd/inspircd-docker"
|
||||||
|
ports:
|
||||||
|
- "6667:6667"
|
||||||
|
|
Loading…
Reference in a new issue