Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20cb33bc66 | ||
|
|
2791bdb08b | ||
|
|
5dd505202a | ||
|
|
d61078002c | ||
|
|
cf5b346558 | ||
|
|
ebbb6658e1 | ||
|
|
369dc0c1ba | ||
|
|
3185f44a93 | ||
|
|
aa8ccde0ed | ||
|
|
d5df0d7416 | ||
|
|
140ca9ed68 | ||
|
|
89d77d52b6 | ||
|
|
5092700275 | ||
|
|
10c365124e | ||
|
|
08bdf4f7a2 | ||
|
|
af557a7e45 | ||
|
|
fa7eb11b1d | ||
|
|
ff1e128f0e | ||
|
|
c9da927c66 | ||
|
|
7b16c5c1c3 | ||
|
|
f809bf8d7c | ||
|
|
44aea427fc | ||
|
|
8f45a57ced | ||
|
|
6a9100309c | ||
|
|
1953878e6b | ||
|
|
35761a5bf7 | ||
|
|
c7fa0cc0ee | ||
|
|
87fcf9d019 | ||
|
|
4b52bc906c | ||
|
|
517a6c5e33 | ||
|
|
636c8a35eb | ||
|
|
a50de600da | ||
|
|
e978d3cb2f | ||
|
|
2b1bdbd3d2 | ||
|
|
7d183b91d1 | ||
|
|
420f380417 | ||
|
|
6f541e2361 | ||
|
|
0737c2761e | ||
|
|
45852a0d53 | ||
|
|
0fcf8d8703 | ||
|
|
41905b006a | ||
|
|
ee3d27701b | ||
|
|
1b5c2fbdc1 | ||
|
|
a865a26093 | ||
|
|
1563e83672 | ||
|
|
cf114b37b3 | ||
|
|
b8ef2b978b | ||
|
|
7e37aee1b9 | ||
|
|
53836e556a | ||
|
|
89052bbfd9 | ||
|
|
07eb12d406 | ||
|
|
3272cd6fb2 | ||
|
|
7ab97a39ca | ||
|
|
11c5a9942e | ||
|
|
251dec454c | ||
|
|
1031dbf672 | ||
|
|
581f00b9fb |
26
.github/workflows/ci.yml
vendored
Normal file
26
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags: [ "v*" ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
test-and-clippy:
|
||||
name: Unit testing and linting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@1.93.0
|
||||
- name: Install SQLite3
|
||||
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
|
||||
- run: cargo test --all-features
|
||||
- run: cargo clippy
|
||||
@@ -1,33 +1,31 @@
|
||||
name: CI (main and tags)
|
||||
name: Publish
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
workflow_run:
|
||||
workflows: [ "CI" ]
|
||||
types: [ "completed" ]
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: publish-${{ github.event.workflow_run.id || github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
test-and-clippy:
|
||||
name: Unit testing and linting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@1.93.0
|
||||
- name: Install SQLite3
|
||||
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
|
||||
- run: cargo test --all-features
|
||||
- run: cargo clippy
|
||||
|
||||
docker-clean-metadata:
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
(
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
startsWith(github.event.workflow_run.head_branch || '', 'v')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
json: ${{ steps.meta.outputs.json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -35,10 +33,17 @@ jobs:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
type=semver,pattern={{raw}}
|
||||
type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}
|
||||
type=semver,pattern={{raw}},value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch || '', 'v') }}
|
||||
|
||||
docker-build:
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
(
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
startsWith(github.event.workflow_run.head_branch || '', 'v')
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -57,6 +62,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
@@ -68,8 +76,8 @@ jobs:
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
type=semver,pattern={{raw}}
|
||||
type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}
|
||||
type=semver,pattern={{raw}},value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch || '', 'v') }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
suffix=-${{ matrix.arch }},onlatest=true
|
||||
@@ -84,6 +92,16 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
docker-manifest:
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
(
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
startsWith(github.event.workflow_run.head_branch || '', 'v')
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- docker-build
|
||||
- docker-clean-metadata
|
||||
@@ -103,5 +121,4 @@ jobs:
|
||||
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker manifest create ${{ matrix.image }} ${{ matrix.image }}-amd64 ${{ matrix.image }}-arm64
|
||||
docker manifest push ${{ matrix.image }}
|
||||
docker buildx imagetools create -t ${{ matrix.image }} ${{ matrix.image }}-amd64 ${{ matrix.image }}-arm64
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,3 +1,35 @@
|
||||
# (2026-05-21) Version 1.19.2
|
||||
|
||||
- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.40.0.
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
|
||||
# (2026-05-09) Version 1.19.1
|
||||
|
||||
- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.38.0.
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
|
||||
# (2026-05-09) Version 1.19.0
|
||||
|
||||
- (**Internal Improvement**) Update [matrix-sdk](https://crates.io/crates/matrix-sdk) from 0.16 to 0.17 and [mxlink](https://crates.io/crates/mxlink) to 1.14.0. matrix-sdk 0.17 dropped its `native-tls` feature and now uses [rustls](https://github.com/rustls/rustls) exclusively as its TLS backend.
|
||||
|
||||
- (**Internal Improvement**) Bump the pinned Rust toolchain from 1.93.0 to 1.95.0 (in `rust-toolchain.toml` and the Docker build images).
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
|
||||
# (2026-04-11) Version 1.18.0
|
||||
|
||||
- (**Bugfix**) Fix the bot not sending a welcome message when joining a room on homeservers (like [Continuwuity](https://continuwuity.org/)) that place the join membership event in the sync response's `state` block rather than the `timeline` block, via [mxlink](https://crates.io/crates/mxlink) 1.13.1
|
||||
|
||||
- (**Improvement**) Update [tiktoken-rs](https://crates.io/crates/tiktoken-rs) to 0.11, adding tokenization support for newer GPT models (gpt-5.x, codex, etc.) and fixing context sizes for o1-mini/chatgpt-4o/gpt-4.5
|
||||
|
||||
- (**Internal Improvement**) Dependency updates
|
||||
|
||||
|
||||
# (2026-03-25) Version 1.17.0
|
||||
|
||||
- (**Feature**) Add `text-generation sender-context-mode` for attaching sender metadata to conversation messages. See the [💬 Text Generation](./docs/configuration/text-generation.md#-sender-context-mode) documentation for details. Thanks to [kschwank](https://github.com/kschwank) for the contribution in [#104](https://github.com/etkecc/baibot/pull/104)!
|
||||
|
||||
1125
Cargo.lock
generated
1125
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -7,7 +7,7 @@ license = "AGPL-3.0-or-later"
|
||||
readme = "README.md"
|
||||
keywords = ["matrix", "chat", "bot", "AI", "LLM"]
|
||||
include = ["/etc/assets/baibot-torso-768.png", "/src", "/README.md", "/CHANGELOG.md", "/LICENSE"]
|
||||
version = "1.17.0"
|
||||
version = "1.19.2"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -17,15 +17,14 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
anthropic = { git = "https://github.com/etkecc/anthropic-rs.git", branch = "fix-content-block-image" }
|
||||
anyhow = "1.0.*"
|
||||
async-openai = { version = "0.34.0", features = ["audio", "chat-completion", "image", "responses"] }
|
||||
async-openai = { version = "0.40.0", features = ["audio", "chat-completion", "image", "responses"] }
|
||||
base64 = "0.22.*"
|
||||
chrono = { version = "0.4.*", default-features = false, features = ["std", "now"] }
|
||||
# We'd rather not depend on this, but we cannot use the ruma-events EventContent macro without it.
|
||||
# We add the `native-tls` feature, because of https://github.com/etkecc/rust-mxlink/issues/1
|
||||
matrix-sdk = { version = "0.16.0", default-features = false, features = ["native-tls"] }
|
||||
matrix-sdk = { version = "0.17.0", default-features = false }
|
||||
mime_guess = "2.0.*"
|
||||
mxidwc = "1.0.*"
|
||||
mxlink = ">=1.13.0"
|
||||
mxlink = ">=1.14.0"
|
||||
etke_openai_api_rust = "0.1.*"
|
||||
quick_cache = "0.6.*"
|
||||
regex = "1.12.*"
|
||||
@@ -33,8 +32,8 @@ serde = { version = "1.0.*", features = ["derive"], default-features = false }
|
||||
serde_json = "1.0.*"
|
||||
serde_yaml_ng = "0.10.*"
|
||||
tempfile = "3.27.*"
|
||||
tiktoken-rs = { version = "0.9.*", default-features = false }
|
||||
tokio = { version = "1.50.*", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
tiktoken-rs = { version = "0.11.*", default-features = false }
|
||||
tokio = { version = "1.52.*", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
tracing = "0.1.*"
|
||||
tracing-subscriber = { version = "0.3.*", features = ["env-filter"] }
|
||||
url = "2.5.*"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# #
|
||||
#######################################
|
||||
|
||||
FROM docker.io/rust:1.93.1-slim-trixie AS build
|
||||
FROM docker.io/rust:1.95.0-slim-trixie AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# #
|
||||
#######################################
|
||||
|
||||
FROM docker.io/rust:1.93.1-slim-trixie AS build
|
||||
FROM docker.io/rust:1.95.0-slim-trixie AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
continuwuity:
|
||||
image: forgejo.ellis.link/continuwuation/continuwuity:v0.5.6
|
||||
image: forgejo.ellis.link/continuwuation/continuwuity:v0.5.9
|
||||
user: "${UID}:${GID}"
|
||||
restart: unless-stopped
|
||||
cap_drop:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
element-web:
|
||||
image: ghcr.io/element-hq/element-web:v1.12.13
|
||||
image: ghcr.io/element-hq/element-web:v1.12.18
|
||||
user: "${UID}:${GID}"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
ollama:
|
||||
image: docker.io/ollama/ollama:0.18.2
|
||||
image: docker.io/ollama/ollama:0.24.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SERVICE_OLLAMA_BIND_PORT_HTTP}:11434"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: docker.io/postgres:18.3-alpine
|
||||
image: docker.io/postgres:18.4-alpine
|
||||
user: ${UID}:${GID}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:v1.150.0
|
||||
image: ghcr.io/element-hq/synapse:v1.153.0
|
||||
user: "${UID}:${GID}"
|
||||
restart: unless-stopped
|
||||
entrypoint: python
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
prek = "0.3.2"
|
||||
prek = "0.4.1"
|
||||
|
||||
[settings]
|
||||
# Disable automatic trust prompts - we trust this config
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.93.0"
|
||||
channel = "1.95.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "default"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use tiktoken_rs::CoreBPE;
|
||||
use tiktoken_rs::get_bpe_from_tokenizer;
|
||||
use tiktoken_rs::bpe_for_tokenizer;
|
||||
use tiktoken_rs::tokenizer;
|
||||
|
||||
use super::{Author, Message, MessageContent};
|
||||
|
||||
fn get_bpe_for_model(model: &str) -> CoreBPE {
|
||||
fn get_bpe_for_model(model: &str) -> &'static CoreBPE {
|
||||
let tokenizer = tokenizer::get_tokenizer(model)
|
||||
.or_else(|| tokenizer::get_tokenizer("gpt-4"))
|
||||
.unwrap();
|
||||
|
||||
get_bpe_from_tokenizer(tokenizer).unwrap()
|
||||
bpe_for_tokenizer(tokenizer).unwrap()
|
||||
}
|
||||
|
||||
pub fn shorten_messages_list_to_context_size(
|
||||
@@ -26,7 +26,7 @@ pub fn shorten_messages_list_to_context_size(
|
||||
// We want to retain the prompt in all cases, so we always count it first.
|
||||
// We also always reserve enough tokens for the maximum response we expect.
|
||||
let mut current_context_length: u32 = if let Some(prompt_message) = prompt_message {
|
||||
calculate_token_size_for_message(&bpe, model, prompt_message)
|
||||
calculate_token_size_for_message(bpe, model, prompt_message)
|
||||
+ max_response_tokens.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
@@ -37,7 +37,7 @@ pub fn shorten_messages_list_to_context_size(
|
||||
let mut messages_to_keep: Vec<Message> = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
let tokens_for_message = calculate_token_size_for_message(&bpe, model, &message);
|
||||
let tokens_for_message = calculate_token_size_for_message(bpe, model, &message);
|
||||
|
||||
if current_context_length + tokens_for_message > max_context_tokens {
|
||||
break;
|
||||
@@ -94,7 +94,7 @@ pub mod test {
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let tokens = super::calculate_token_size_for_message(&bpe, model, &message);
|
||||
let tokens = super::calculate_token_size_for_message(bpe, model, &message);
|
||||
|
||||
assert_eq!(8, tokens);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
prompt_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &prompt)
|
||||
super::calculate_token_size_for_message(bpe, model, &prompt)
|
||||
);
|
||||
|
||||
let mut conversation_messages = Vec::new();
|
||||
@@ -132,7 +132,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
first_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &first)
|
||||
super::calculate_token_size_for_message(bpe, model, &first)
|
||||
);
|
||||
|
||||
conversation_messages.push(first);
|
||||
@@ -147,7 +147,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
second_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &second)
|
||||
super::calculate_token_size_for_message(bpe, model, &second)
|
||||
);
|
||||
|
||||
conversation_messages.push(second);
|
||||
@@ -164,7 +164,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
third_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &third)
|
||||
super::calculate_token_size_for_message(bpe, model, &third)
|
||||
);
|
||||
|
||||
conversation_messages.push(third.clone());
|
||||
@@ -181,7 +181,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
forth_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &forth)
|
||||
super::calculate_token_size_for_message(bpe, model, &forth)
|
||||
);
|
||||
|
||||
conversation_messages.push(forth.clone());
|
||||
@@ -227,7 +227,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
prompt_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &prompt)
|
||||
super::calculate_token_size_for_message(bpe, model, &prompt)
|
||||
);
|
||||
|
||||
let mut conversation_messages = Vec::new();
|
||||
@@ -242,7 +242,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
first_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &first)
|
||||
super::calculate_token_size_for_message(bpe, model, &first)
|
||||
);
|
||||
|
||||
conversation_messages.push(first);
|
||||
@@ -257,7 +257,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
second_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &second)
|
||||
super::calculate_token_size_for_message(bpe, model, &second)
|
||||
);
|
||||
|
||||
conversation_messages.push(second);
|
||||
@@ -274,7 +274,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
third_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &third)
|
||||
super::calculate_token_size_for_message(bpe, model, &third)
|
||||
);
|
||||
|
||||
conversation_messages.push(third.clone());
|
||||
@@ -291,7 +291,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
forth_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &forth)
|
||||
super::calculate_token_size_for_message(bpe, model, &forth)
|
||||
);
|
||||
|
||||
conversation_messages.push(forth.clone());
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn get_matrix_messages_in_reply_chain_native(
|
||||
AnySyncMessageLikeEvent::RoomMessage(room_message) => {
|
||||
if let SyncMessageLikeEvent::Original(room_message_original) = room_message {
|
||||
match room_message_original.content.relates_to {
|
||||
Some(Relation::Reply { in_reply_to }) => Some(in_reply_to.event_id.clone()),
|
||||
Some(Relation::Reply(reply)) => Some(reply.in_reply_to.event_id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
@@ -420,11 +420,11 @@ pub async fn determine_interaction_context_for_room_event(
|
||||
)
|
||||
.await
|
||||
}
|
||||
Relation::Reply { in_reply_to } => {
|
||||
Relation::Reply(reply) => {
|
||||
determine_interaction_context_for_room_event_related_to_reply(
|
||||
current_event,
|
||||
current_event_is_mentioning_bot,
|
||||
in_reply_to.event_id.clone(),
|
||||
reply.in_reply_to.event_id.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// rustc 1.94+ trips a query-depth overflow when computing async layouts in
|
||||
// the matrix-sdk timeline future graph. matrix-rust-sdk PR #6489 raises the
|
||||
// limit, but `recursion_limit` is per-crate and applies to the crate currently
|
||||
// being compiled — so the consumer has to repeat it.
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod agent;
|
||||
mod bot;
|
||||
mod controller;
|
||||
|
||||
Reference in New Issue
Block a user