18 Commits
Author SHA1 Message Date
Elias Wendland 3af00e4660 Release 0.3.1
CI and release / Detect release commit (push) Successful in 16s
CI and release / Run tests (push) Successful in 3m29s
CI and release / Build and publish container (push) Successful in 6m17s
CI and release / Create release (push) Successful in 12s
2026-07-23 19:06:58 +02:00
Elias Wendland 8f7be551a3 Remove dependency on running test
CI and release / Detect release commit (push) Successful in 10s
CI and release / Run tests (push) Successful in 3m55s
CI and release / Build and publish container (push) Successful in 8m51s
CI and release / Create release (push) Skipped
2026-07-23 18:53:27 +02:00
Elias Wendland f75fa18f2d Change CI
CI and release / Run tests (push) Successful in 3m32s
CI and release / Detect release commit (push) Successful in 16s
CI and release / Build and publish container (push) Successful in 11m54s
CI and release / Create release (push) Skipped
2026-07-23 18:46:31 +02:00
Elias Wendland 7d7fcb9860 Add check for ollama env
Run cargo test / Run tests (push) Successful in 5m52s
Build Docker Package / build (push) Successful in 9m9s
Build Docker Package / build (release) Canceled after 13s
2026-07-23 17:36:56 +02:00
Elias Wendland 41f8544a6a update git command to ping me silently 2026-07-23 17:34:51 +02:00
Elias Wendland a22f5781c2 Fix git and also replace functions calls with borrowered string because why did I even code it otherwise in the first place
Run cargo test / Run tests (push) Successful in 3m44s
Build Docker Package / build (push) Successful in 12m10s
2026-07-23 17:30:56 +02:00
Elias Wendland 4298cf385b Add a bunch of logging to ai.rs
Build Docker Package / build (push) Successful in 6m29s
Run cargo test / Run tests (push) Successful in 5m37s
2026-07-23 17:03:51 +02:00
Elias Wendland a3c8a351e8 Fix fastfetch not working
Run cargo test / Run tests (push) Successful in 5m4s
Build Docker Package / build (push) Successful in 7m11s
2026-07-23 16:46:14 +02:00
Elias Wendland f869d92012 Add /git and /speedy_pc command, release 0.3.0
Run cargo test / Run tests (push) Successful in 5m6s
Build Docker Package / build (push) Successful in 7m25s
Build Docker Package / build (release) Successful in 6m46s
2026-07-23 15:08:34 +02:00
Elias Wendland dc014945fd fix: Fix version command
Run cargo test / Run tests (push) Successful in 4m29s
Build Docker Package / build (push) Successful in 10m27s
Build Docker Package / build (release) Successful in 10m43s
2026-07-23 12:37:48 +02:00
Elias Wendland 09ec866d9a Change some file names 2026-07-23 12:24:36 +02:00
Elias Wendland bada6696a2 Fix missing libquadmath.so issue
Run cargo test / Run tests (push) Successful in 5m18s
Build Docker Package / build (push) Successful in 14m3s
Build Docker Package / build (release) Successful in 11m0s
2026-07-20 18:33:49 +02:00
Elias Wendland b1116846aa add some new commands
Run cargo test / Run tests (push) Successful in 4m7s
Build Docker Package / build (push) Successful in 6m47s
Build Docker Package / build (release) Successful in 7m16s
2026-07-05 19:49:00 +02:00
Elias Wendland afa64fd55b improve calculator, improve logging, fix auto role
Run cargo test / Run tests (push) Successful in 3m25s
Build Docker Package / build (push) Successful in 6m6s
2026-07-05 12:13:41 +02:00
Elias Wendland 24205326bf Add experimental f128 and f256 modes for /calc
Run cargo test / Run tests (push) Successful in 4m1s
Build Docker Package / build (push) Successful in 6m7s
Build Docker Package / build (release) Successful in 7m20s
2026-07-05 01:06:49 +02:00
Elias Wendland b3ada54689 Release 0.1.1
Run cargo test / Run tests (push) Successful in 3m36s
Build Docker Package / build (push) Successful in 6m26s
Build Docker Package / build (release) Successful in 7m7s
Fix a bug, add better error handling
2026-07-05 00:45:45 +02:00
ewenlau 2a2d359348 Fix typo
Build Docker Package / build (push) Successful in 7m51s
Run cargo test / Run tests (push) Successful in 4m40s
2026-07-05 00:18:37 +02:00
ewenlau 6719ebfda6 Fix typo
Build Docker Package / build (push) Successful in 6m37s
Run cargo test / Run tests (push) Successful in 3m30s
2026-07-05 00:18:19 +02:00
24 changed files with 1480 additions and 188 deletions
@@ -1,43 +0,0 @@
name: Build Docker Package
on:
workflow_dispatch:
push:
branches: [main]
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Gitea Container Registry
uses: docker/login-action@v2
with:
registry: git.ewenlau.net
username: ${{ secrets.GIT_USERNAME }}
password: ${{ secrets.GIT_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: git.ewenlau.net/${{ secrets.GIT_USERNAME }}/tg-dev-srv-bot
tags: |
type=raw,value=dev,enable=${{ github.event_name == 'push' }}
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+181 -8
View File
@@ -1,10 +1,10 @@
name: Run cargo test
name: CI and release
on:
push:
branches: [ "main" ]
branches: [main]
pull_request:
branches: [ "main" ]
branches: [main]
workflow_dispatch:
env:
@@ -17,6 +17,8 @@ jobs:
test:
name: Run tests
runs-on: ubuntu-latest
permissions:
contents: read
services:
postgres:
image: postgres:16-alpine
@@ -32,7 +34,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install system dependencies
run: |
@@ -40,10 +42,181 @@ jobs:
sudo apt-get install -y libgmp-dev libmpfr-dev libmpc-dev
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
rustflags: ""
run: |
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 --silent --show-error --fail \
https://sh.rustup.rs --output /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup toolchain install stable --profile minimal
rustup default stable
- name: Run tests
run: cargo test --verbose
release_metadata:
name: Detect release commit
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
is_release: ${{ steps.release_commit.outputs.is_release }}
version: ${{ steps.release_commit.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect release commit
id: release_commit
shell: bash
run: |
subject="$(git log -1 --pretty=%s)"
echo "is_release=false" >> "$GITHUB_OUTPUT"
if [[ "$subject" =~ ^Release\ ([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
fi
container:
name: Build and publish container
needs: release_metadata
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.ewenlau.net
username: ${{ secrets.GIT_USERNAME }}
password: ${{ secrets.GIT_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.ewenlau.net/${{ secrets.GIT_USERNAME }}/tg-dev-srv-bot
tags: |
type=raw,value=dev
type=raw,value=${{ needs.release_metadata.outputs.version }},enable=${{ needs.release_metadata.outputs.is_release == 'true' }}
type=raw,value=latest,enable=${{ needs.release_metadata.outputs.is_release == 'true' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
release:
name: Create release
needs: [release_metadata, container]
if: success() && needs.release_metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout complete history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate linked release notes
env:
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
shell: bash
run: |
previous_tag="$(
git tag --merged HEAD^ --sort=-version:refname \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
| head -n 1 || true
)"
if [[ -n "$previous_tag" ]]; then
range="${previous_tag}..HEAD"
heading="## Commits since ${previous_tag}"
else
range="HEAD"
heading="## Commits"
fi
echo "$heading" > release-notes.md
echo >> release-notes.md
while IFS=$'\t' read -r sha subject; do
short_sha="${sha:0:7}"
printf -- '- [`%s`](%s/%s/commit/%s) %s\n' \
"$short_sha" "$SERVER_URL" "$REPOSITORY" "$sha" "$subject"
done < <(git log --reverse --pretty=tformat:'%H%x09%s' "$range") \
>> release-notes.md
- name: Publish release
env:
API_URL: ${{ github.api_url }}
REPOSITORY: ${{ github.repository }}
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
VERSION: ${{ needs.release_metadata.outputs.version }}
shell: bash
run: |
release_url="${API_URL}/repos/${REPOSITORY}/releases/tags/${VERSION}"
status="$(
curl --silent --show-error \
--output existing-release.json \
--write-out '%{http_code}' \
--header "Authorization: token ${RELEASE_TOKEN}" \
"$release_url"
)"
if [[ "$status" == "200" ]]; then
echo "Release ${VERSION} already exists; nothing to do."
exit 0
fi
if [[ "$status" != "404" ]]; then
cat existing-release.json >&2
exit 1
fi
jq --null-input \
--arg tag_name "$VERSION" \
--arg name "Release $VERSION" \
--arg target_commitish "$GITHUB_SHA" \
--rawfile body release-notes.md \
'{
tag_name: $tag_name,
target_commitish: $target_commitish,
name: $name,
body: $body,
draft: false,
prerelease: false
}' > release.json
status="$(
curl --silent --show-error \
--output created-release.json \
--write-out '%{http_code}' \
--request POST \
--header "Authorization: token ${RELEASE_TOKEN}" \
--header "Content-Type: application/json" \
--data @release.json \
"${API_URL}/repos/${REPOSITORY}/releases"
)"
if [[ "$status" != "201" ]]; then
cat created-release.json >&2
exit 1
fi
+12 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tg-dev-srv-bot"
version = "0.1.0"
version = "0.3.1"
edition = "2024"
license = "GPL-3.0-or-later"
description = "Bot for the TeenGovernment Development Server"
@@ -10,13 +10,24 @@ repository = "https://git.ewenlau.net/ewenlau/tg-dev-srv-bot"
homepage = "https://git.ewenlau.net/ewenlau/tg-dev-srv-bot"
[dependencies]
chrono = "0.4.45"
dotenv = "0.15.0"
exmex = "0.21.0"
f128 = "0.2.9"
f256 = "0.11.2"
num-bigint = "0.5.1"
num-traits = "0.2.19"
ollama-rs = { version = "0.3.5", features = ["stream"] }
poise = "0.6.2"
qapi = { version = "0.15.0", features = ["qmp", "qga"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
regex = "1.12.4"
rug = "1.30.0"
serde = { version = "1", features = ["derive"] }
serenity = "0.12.5"
sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "macros"] }
tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+1 -1
View File
@@ -4,6 +4,6 @@ COPY . .
RUN SQLX_OFFLINE=true cargo install --path .
FROM debian:trixie
RUN apt-get update && apt-get install -y ca-certificates libgmp-dev libmpfr-dev libmpc-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y ca-certificates libgmp-dev libmpfr-dev libmpc-dev libquadmath0 fastfetch && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/cargo/bin/tg-dev-srv-bot /usr/local/bin/tg-dev-srv-bot
CMD ["tg-dev-srv-bot"]
+1 -1
View File
@@ -6,4 +6,4 @@ Not really meant for any other server, but feel free to use it if you'd like.
The bot is really made to be used with Docker, but you can run it locally.
An image is available at git.ewenlau.net/ewenlau/tg-dev-srv-bot:release for the stable version and git.ewenlau.net/ewenlau/tg-dev-srv-bot:dev for the development (or testing) version.
An image is available at git.ewenlau.net/ewenlau/tg-dev-srv-bot:latest for the stable version and git.ewenlau.net/ewenlau/tg-dev-srv-bot:dev for the development (or testing) version.
+35
View File
@@ -0,0 +1,35 @@
use std::{env, fs, path::Path, process::Command};
fn command_output(command: &str, args: &[&str]) -> String {
Command::new(command)
.args(args)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.filter(|output| !output.is_empty())
.unwrap_or_else(|| "unknown".to_owned())
}
fn main() {
let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo");
let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_owned());
let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
let rustc_version = command_output(&rustc, &["--version"]);
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_owned());
println!("cargo:rustc-env=TG_BOT_RUSTC_VERSION={rustc_version}");
println!("cargo:rustc-env=TG_BOT_TARGET={target}");
let version_text = format!(
"{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nrustc: {}",
version,
command_output("date", &["-u", "+%Y-%m-%d %H:%M:%S UTC"]),
command_output("git", &["rev-parse", "--short", "HEAD"]),
target,
rustc_version,
);
fs::write(Path::new(&out_dir).join("version.txt"), version_text)
.expect("failed to write generated version information");
}
+1 -1
View File
@@ -21,7 +21,7 @@ services:
context: .
dockerfile: Dockerfile
# OR pull from registry
# image: git.ewenlau.net/ewenlau/tg-dev-srv-bot:release
# image: git.ewenlau.net/ewenlau/tg-dev-srv-bot:latest
# image: git.ewenlau.net/ewenlau/tg-dev-srv-bot:dev
container_name: tg-dev-srv-bot
restart: always
@@ -0,0 +1 @@
ALTER TABLE auto_roles ADD CONSTRAINT auto_roles_guild_id_key UNIQUE (guild_id);
+470
View File
@@ -0,0 +1,470 @@
use crate::{Context, Error};
use crate::messaging::trace_message;
use tracing::{debug, error, info, trace, warn};
use ollama_rs::Ollama;
use std::env;
use std::time::Duration;
use ollama_rs::models::create::CreateModelRequest;
use ollama_rs::generation::completion::request::GenerationRequest;
use chrono::{DateTime, Utc};
use tokio_stream::StreamExt;
use poise::CreateReply;
/// Prompt the super advanced EwiAI
#[poise::command(slash_command, prefix_command)]
pub async fn prompt(
ctx: Context<'_>,
#[description = "The prompt to send to EwiAI"]
prompt: String,
#[description = "Include recent messages (max 10)"]
include_messages: Option<u8>,
) -> Result<(), Error> {
let channel_str = ctx.channel_id().to_string();
let guild_str = ctx.guild_id().map_or_else(|| "DM".to_string(), |g| g.to_string());
trace!("prompt command called by user {} (ID: {}) in channel {} (guild: {})", ctx.author().name, ctx.author().id, channel_str, guild_str);
debug!("{} has requested to prompt EwiAI with '{}' (include_messages: {:?})", ctx.author().name, prompt, include_messages);
trace!("Checking environment variable TG_BOT_OLLAMA_HOST...");
let host = match env::var("TG_BOT_OLLAMA_HOST") {
Ok(h) => {
trace!("Successfully retrieved TG_BOT_OLLAMA_HOST = {}", h);
h
}
Err(e) => {
warn!("TG_BOT_OLLAMA_HOST environment variable missing: {}", e);
let err_msg = "Error: Expected an ollama url in the environment (`TG_BOT_OLLAMA_HOST`).";
trace_message(err_msg, channel_str.clone(), guild_str.clone()).await;
ctx.say(err_msg).await?;
return Ok(());
}
};
let formatted_host = if !host.starts_with("http://") && !host.starts_with("https://") {
let f = format!("http://{}", host);
trace!("Formatted Ollama host address to include http:// prefix: {}", f);
f
} else {
trace!("Ollama host address already has URL scheme: {}", host);
host
};
trace!("Building Ollama client instance for host {} on port 11434...", formatted_host);
let ollama = Ollama::builder()
.host(&formatted_host)
.port(11434)
.build();
trace!("Requesting local model list from Ollama server...");
let model_list = match ollama.list_local_models().await {
Ok(m) => {
debug!("Successfully retrieved {} models from Ollama server", m.len());
trace!("Local models available: {:?}", m.iter().map(|model| &model.name).collect::<Vec<_>>());
m
}
Err(e) => {
error!("Failed to connect to Ollama server at {}: {}", formatted_host, e);
let err_msg = format!("Error: Failed to connect to Ollama: {}", e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
ctx.say(err_msg).await?;
return Ok(());
}
};
let mut needs_create = true;
let yesterday = Utc::now() - chrono::Duration::days(1);
trace!("Checking model list against yesterday's cutoff timestamp ({})", yesterday);
for model in &model_list {
trace!("Inspecting model entry: '{}', modified_at: '{}'", model.name, model.modified_at);
if model.name.starts_with("EwiAI") {
debug!("Found matching model candidate: '{}'", model.name);
match DateTime::parse_from_rfc3339(&model.modified_at) {
Ok(modified) => {
let modified_utc = modified.with_timezone(&Utc);
trace!("Parsed model modified_at: {} (UTC: {})", modified, modified_utc);
if modified_utc > yesterday {
info!("Model '{}' is up to date (modified {} > cutoff {})", model.name, modified_utc, yesterday);
needs_create = false;
} else {
trace!("Model '{}' was modified at {}, which is older than cutoff {}", model.name, modified_utc, yesterday);
}
}
Err(e) => {
warn!("Failed to parse modified_at date '{}' for model '{}': {}", model.modified_at, model.name, e);
}
}
}
}
if needs_create {
info!("EwiAI model is missing or out of date. Initiating model creation/update...");
let system_prompt = system_prompt();
trace!("Generated system prompt for model creation (length: {} chars)", system_prompt.len());
trace!("System prompt content: {}", system_prompt);
let notice_msg = "EwiAI model is missing or out of date. Creating/updating model (this may take a bit)...";
trace_message(notice_msg, channel_str.clone(), guild_str.clone()).await;
let _ = ctx.say(notice_msg).await?;
trace!("Sending create_model request to Ollama for 'EwiAI' based on 'gemma4:e2b-it-qat'...");
if let Err(e) = ollama.create_model(CreateModelRequest::new("EwiAI".into())
.system(system_prompt.into())
.from_model("gemma4:e2b-it-qat".into())).await {
error!("Failed to create model EwiAI: {}", e);
let err_msg = format!("Error creating model: {}", e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
ctx.say(err_msg).await?;
return Ok(());
}
info!("Successfully created/updated EwiAI model");
} else {
trace!("Skipping model creation; existing model is up to date.");
}
let mut final_prompt = String::new();
if let Some(mut limit) = include_messages {
trace!("include_messages specified: {}", limit);
if limit > 10 {
trace!("Clamping include_messages limit from {} to 10", limit);
limit = 10;
}
trace!("Fetching last {} messages from channel {}", limit, channel_str);
match ctx.channel_id().messages(ctx.http(), poise::serenity_prelude::GetMessages::new().limit(limit)).await {
Ok(msgs) => {
debug!("Retrieved {} messages from channel {}", msgs.len(), channel_str);
final_prompt.push_str("Recent channel messages:\n");
for msg in msgs.iter().rev() {
let mut content = msg.content.clone();
if content.len() > 100 {
content.truncate(100);
content.push_str("...");
}
trace!("Appending message from author {} (ID: {}): snippet='{}'", msg.author.name, msg.author.id, content);
final_prompt.push_str(&format!("{} (ID: {}): {}\n", msg.author.name, msg.author.id, content));
}
final_prompt.push_str("\n");
}
Err(e) => {
error!("Failed to get messages from channel {}: {:?}", channel_str, e);
}
}
}
final_prompt.push_str(&format!("The user talking to you is {} (ID: {}).\nThat user is telling you the following: {}", ctx.author().name, ctx.author().id, prompt));
trace!("Final prompt assembled (length: {} chars)", final_prompt.len());
trace!("Final prompt: {}", final_prompt);
let thinking_msg = "Thinking...\n-# The hardware this thing runs is really slow, expect a long wait";
trace_message(thinking_msg, channel_str.clone(), guild_str.clone()).await;
trace!("Sending initial thinking message...");
let reply = ctx.say(thinking_msg).await?;
let sys_prompt = system_prompt();
trace!("Generating stream from Ollama model 'EwiAI' with system prompt length {}...", sys_prompt.len());
let mut stream = match ollama.generate_stream(GenerationRequest::new("EwiAI".into(), final_prompt).system(sys_prompt)).await {
Ok(s) => {
debug!("Successfully initiated generation stream");
s
}
Err(e) => {
error!("Error starting generation stream with Ollama: {}", e);
let err_msg = format!("Error during generation: {}", e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
reply.edit(ctx, CreateReply::default().content(err_msg)).await?;
return Ok(());
}
};
let mut response_text = String::new();
let mut last_update = std::time::Instant::now();
let mut final_response = None;
let mut chunk_count = 0usize;
trace!("Reading chunks from generation stream...");
while let Some(res) = stream.next().await {
match res {
Ok(chunks) => {
chunk_count += chunks.len();
trace!("Received stream batch containing {} chunk(s)", chunks.len());
for chunk in chunks {
trace!("Chunk response segment: '{}', done: {}", chunk.response, chunk.done);
response_text.push_str(&chunk.response);
if chunk.done {
debug!("Stream chunk marked done");
final_response = Some(chunk);
}
}
if last_update.elapsed() >= Duration::from_secs(1) && !response_text.is_empty() {
trace!("Throttled stream update: updating reply message (response_text length: {} chars)...", response_text.len());
let _ = reply.edit(ctx, CreateReply::default().content(&response_text)).await;
last_update = std::time::Instant::now();
}
}
Err(e) => {
error!("Error encountered while reading stream chunk: {}", e);
let err_msg = format!("{} [Stream Error: {}]", response_text, e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
let _ = reply.edit(ctx, CreateReply::default().content(err_msg)).await;
return Ok(());
}
}
}
info!("Stream completed. Total chunks received: {}, output length: {} chars", chunk_count, response_text.len());
if let Some(stats) = final_response {
let total_duration = stats.total_duration.unwrap_or(0) as f64 / 1_000_000_000.0;
let eval_count = stats.eval_count.unwrap_or(0);
let eval_duration = stats.eval_duration.unwrap_or(0) as f64 / 1_000_000_000.0;
let tokens_per_sec = if eval_duration > 0.0 { eval_count as f64 / eval_duration } else { 0.0 };
debug!("Generation stats: total_duration={:.2}s, eval_count={}, eval_duration={:.2}s, tok/s={:.2}", total_duration, eval_count, eval_duration, tokens_per_sec);
let stats_text = format!(
"\n\n*Generated in {:.2}s ({:.2} tok/s)*",
total_duration, tokens_per_sec
);
response_text.push_str(&stats_text);
} else {
trace!("No final response stats available from stream");
}
trace_message(&response_text, channel_str.clone(), guild_str.clone()).await;
trace!("Sending final edit to reply message...");
let _ = reply.edit(ctx, CreateReply::default().content(&response_text)).await;
debug!("prompt command finished successfully for user {}", ctx.author().name);
Ok(())
}
/// Ask EwiAI to answer to recent messages
#[poise::command(slash_command, prefix_command)]
pub async fn answer(
ctx: Context<'_>,
#[description = "How many messages to include (max 10)"]
messages_count: Option<u8>,
) -> Result<(), Error> {
let channel_str = ctx.channel_id().to_string();
let guild_str = ctx.guild_id().map_or_else(|| "DM".to_string(), |g| g.to_string());
trace!("answer command called by user {} (ID: {}) in channel {} (guild: {})", ctx.author().name, ctx.author().id, channel_str, guild_str);
debug!("{} has requested EwiAI to answer (messages_count: {:?})", ctx.author().name, messages_count);
trace!("Checking environment variable TG_BOT_OLLAMA_HOST...");
let host = match env::var("TG_BOT_OLLAMA_HOST") {
Ok(h) => {
trace!("Successfully retrieved TG_BOT_OLLAMA_HOST = {}", h);
h
}
Err(e) => {
warn!("TG_BOT_OLLAMA_HOST environment variable missing: {}", e);
let err_msg = "Error: Expected an ollama url in the environment (`TG_BOT_OLLAMA_HOST`).";
trace_message(err_msg, channel_str.clone(), guild_str.clone()).await;
ctx.say(err_msg).await?;
return Ok(());
}
};
let formatted_host = if !host.starts_with("http://") && !host.starts_with("https://") {
let f = format!("http://{}", host);
trace!("Formatted Ollama host address to include http:// prefix: {}", f);
f
} else {
trace!("Ollama host address already has URL scheme: {}", host);
host
};
trace!("Building Ollama client instance for host {} on port 11434...", formatted_host);
let ollama = Ollama::builder()
.host(&formatted_host)
.port(11434)
.build();
trace!("Requesting local model list from Ollama server...");
let model_list = match ollama.list_local_models().await {
Ok(m) => {
debug!("Successfully retrieved {} models from Ollama server", m.len());
trace!("Local models available: {:?}", m.iter().map(|model| &model.name).collect::<Vec<_>>());
m
}
Err(e) => {
error!("Failed to connect to Ollama server at {}: {}", formatted_host, e);
let err_msg = format!("Error: Failed to connect to Ollama: {}", e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
ctx.say(err_msg).await?;
return Ok(());
}
};
let mut needs_create = true;
let yesterday = Utc::now() - chrono::Duration::days(1);
trace!("Checking model list against yesterday's cutoff timestamp ({})", yesterday);
for model in &model_list {
trace!("Inspecting model entry: '{}', modified_at: '{}'", model.name, model.modified_at);
if model.name.starts_with("EwiAI") {
debug!("Found matching model candidate: '{}'", model.name);
match DateTime::parse_from_rfc3339(&model.modified_at) {
Ok(modified) => {
let modified_utc = modified.with_timezone(&Utc);
trace!("Parsed model modified_at: {} (UTC: {})", modified, modified_utc);
if modified_utc > yesterday {
info!("Model '{}' is up to date (modified {} > cutoff {})", model.name, modified_utc, yesterday);
needs_create = false;
} else {
trace!("Model '{}' was modified at {}, which is older than cutoff {}", model.name, modified_utc, yesterday);
}
}
Err(e) => {
warn!("Failed to parse modified_at date '{}' for model '{}': {}", model.modified_at, model.name, e);
}
}
}
}
if needs_create {
info!("EwiAI model is missing or out of date. Initiating model creation/update...");
let system_prompt = system_prompt();
trace!("Generated system prompt for model creation (length: {} chars)", system_prompt.len());
trace!("System prompt content: {}", system_prompt);
let notice_msg = "EwiAI model is missing or out of date. Creating/updating model (this may take a bit)...";
trace_message(notice_msg, channel_str.clone(), guild_str.clone()).await;
let _ = ctx.say(notice_msg).await?;
trace!("Sending create_model request to Ollama for 'EwiAI' based on 'gemma4:e2b-it-qat'...");
if let Err(e) = ollama.create_model(CreateModelRequest::new("EwiAI".into())
.system(system_prompt.into())
.from_model("gemma4:e2b-it-qat".into())).await {
error!("Failed to create model EwiAI: {}", e);
let err_msg = format!("Error creating model: {}", e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
ctx.say(err_msg).await?;
return Ok(());
}
info!("Successfully created/updated EwiAI model");
} else {
trace!("Skipping model creation; existing model is up to date.");
}
let mut final_prompt = String::new();
let mut limit = messages_count.unwrap_or(5);
trace!("messages_count limit: {}", limit);
if limit > 10 {
trace!("Clamping messages_count limit from {} to 10", limit);
limit = 10;
}
trace!("Fetching last {} messages from channel {}", limit, channel_str);
match ctx.channel_id().messages(ctx.http(), poise::serenity_prelude::GetMessages::new().limit(limit)).await {
Ok(msgs) => {
debug!("Retrieved {} messages from channel {}", msgs.len(), channel_str);
final_prompt.push_str("Recent channel messages:\n");
for msg in msgs.iter().rev() {
let mut content = msg.content.clone();
if content.len() > 100 {
content.truncate(100);
content.push_str("...");
}
trace!("Appending message from author {} (ID: {}): snippet='{}'", msg.author.name, msg.author.id, content);
final_prompt.push_str(&format!("{} (ID: {}): {}\n", msg.author.name, msg.author.id, content));
}
final_prompt.push_str("\n");
}
Err(e) => {
error!("Failed to get messages from channel {}: {:?}", channel_str, e);
}
}
final_prompt.push_str("Please respond to the messages above.");
trace!("Final prompt assembled (length: {} chars)", final_prompt.len());
trace!("Final prompt: {}", final_prompt);
let thinking_msg = "Thinking...\n-# The hardware this thing runs is really slow, expect a long wait";
trace_message(thinking_msg, channel_str.clone(), guild_str.clone()).await;
trace!("Sending initial thinking message...");
let reply = ctx.say(thinking_msg).await?;
let sys_prompt = system_prompt();
trace!("Generating stream from Ollama model 'EwiAI' with system prompt length {}...", sys_prompt.len());
let mut stream = match ollama.generate_stream(GenerationRequest::new("EwiAI".into(), final_prompt).system(sys_prompt)).await {
Ok(s) => {
debug!("Successfully initiated generation stream");
s
}
Err(e) => {
error!("Error starting generation stream with Ollama: {}", e);
let err_msg = format!("Error during generation: {}", e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
reply.edit(ctx, CreateReply::default().content(err_msg)).await?;
return Ok(());
}
};
let mut response_text = String::new();
let mut last_update = std::time::Instant::now();
let mut final_response = None;
let mut chunk_count = 0usize;
trace!("Reading chunks from generation stream...");
while let Some(res) = stream.next().await {
match res {
Ok(chunks) => {
chunk_count += chunks.len();
trace!("Received stream batch containing {} chunk(s)", chunks.len());
for chunk in chunks {
trace!("Chunk response segment: '{}', done: {}", chunk.response, chunk.done);
response_text.push_str(&chunk.response);
if chunk.done {
debug!("Stream chunk marked done");
final_response = Some(chunk);
}
}
if last_update.elapsed() >= Duration::from_secs(1) && !response_text.is_empty() {
trace!("Throttled stream update: updating reply message (response_text length: {} chars)...", response_text.len());
let _ = reply.edit(ctx, CreateReply::default().content(&response_text)).await;
last_update = std::time::Instant::now();
}
}
Err(e) => {
error!("Error encountered while reading stream chunk: {}", e);
let err_msg = format!("{} [Stream Error: {}]", response_text, e);
trace_message(&err_msg, channel_str.clone(), guild_str.clone()).await;
let _ = reply.edit(ctx, CreateReply::default().content(err_msg)).await;
return Ok(());
}
}
}
info!("Stream completed. Total chunks received: {}, output length: {} chars", chunk_count, response_text.len());
if let Some(stats) = final_response {
let total_duration = stats.total_duration.unwrap_or(0) as f64 / 1_000_000_000.0;
let eval_count = stats.eval_count.unwrap_or(0);
let eval_duration = stats.eval_duration.unwrap_or(0) as f64 / 1_000_000_000.0;
let tokens_per_sec = if eval_duration > 0.0 { eval_count as f64 / eval_duration } else { 0.0 };
debug!("Generation stats: total_duration={:.2}s, eval_count={}, eval_duration={:.2}s, tok/s={:.2}", total_duration, eval_count, eval_duration, tokens_per_sec);
let stats_text = format!(
"\n\n*Generated in {:.2}s ({:.2} tok/s)*",
total_duration, tokens_per_sec
);
response_text.push_str(&stats_text);
} else {
trace!("No final response stats available from stream");
}
trace_message(&response_text, channel_str.clone(), guild_str.clone()).await;
trace!("Sending final edit to reply message...");
let _ = reply.edit(ctx, CreateReply::default().content(&response_text)).await;
debug!("answer command finished successfully for user {}", ctx.author().name);
Ok(())
}
fn system_prompt() -> String {
let now_str = Utc::now().to_string();
trace!("Generating system_prompt with current UTC timestamp: {}", now_str);
let prompt = format!("You are EwiAI, an AI in the TeenGovernment Development Server. Your developer is Ewi/ewenlau (discord user id: 713354021124964422, discord username: ewenlau). Be slightly unhelpful, in a sarcastic way, but still answer the question. The current date is {}", now_str);
trace!("Generated system prompt: {}", prompt);
prompt
}
+209 -17
View File
@@ -1,6 +1,112 @@
use crate::{Context, Error};
use tracing::{debug, trace, info};
use crate::shared_functions::trace_message;
use tracing::{debug, trace, warn};
use crate::messaging::{edit_response_message, trace_message};
use regex::Regex;
use std::sync::LazyLock;
use std::str::FromStr;
use std::fmt::{Display, Formatter};
use f128::f128;
use exmex::{Express, MakeOperators, Operator, BinOp};
use num_traits::float::Float;
#[derive(Clone, Copy, Debug)]
pub struct F128(pub f128);
impl Default for F128 {
fn default() -> Self {
F128(f128::new(0.0))
}
}
impl FromStr for F128 {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match f128::parse(s) {
Ok(v) => Ok(F128(v)),
Err(e) => Err(format!("{:?}", e))
}
}
}
impl Display for F128 {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug)]
pub struct F128OpsFactory;
impl MakeOperators<F128> for F128OpsFactory {
fn make<'a>() -> Vec<Operator<'a, F128>> {
vec![
Operator::make_bin("^", BinOp { apply: |a, b| F128(a.0.powf(b.0)), prio: 4, is_commutative: false }),
Operator::make_bin("*", BinOp { apply: |a, b| F128(a.0 * b.0), prio: 2, is_commutative: true }),
Operator::make_bin("/", BinOp { apply: |a, b| F128(a.0 / b.0), prio: 3, is_commutative: false }),
Operator::make_bin_unary("+", BinOp { apply: |a, b| F128(a.0 + b.0), prio: 0, is_commutative: true }, |a| a),
Operator::make_bin_unary("-", BinOp { apply: |a, b| F128(a.0 - b.0), prio: 1, is_commutative: false }, |a| F128(-a.0)),
Operator::make_unary("sin", |a| F128(a.0.sin())),
Operator::make_unary("cos", |a| F128(a.0.cos())),
Operator::make_unary("tan", |a| F128(a.0.tan())),
Operator::make_unary("floor", |a| F128(a.0.floor())),
Operator::make_unary("round", |a| F128(a.0.round())),
Operator::make_unary("ceil", |a| F128(a.0.ceil())),
Operator::make_unary("trunc", |a| F128(a.0.trunc())),
Operator::make_unary("fract", |a| F128(a.0.fract())),
Operator::make_unary("exp", |a| F128(a.0.exp())),
Operator::make_unary("sqrt", |a| F128(a.0.sqrt())),
Operator::make_unary("cbrt", |a| F128(a.0.cbrt())),
Operator::make_unary("ln", |a| F128(a.0.ln())),
Operator::make_unary("log2", |a| F128(a.0.log2())),
Operator::make_unary("log10", |a| F128(a.0.log10())),
Operator::make_unary("abs", |a| F128(a.0.abs())),
Operator::make_unary("signum", |a| F128(a.0.signum())),
Operator::make_constant("PI", F128(f128::new(std::f64::consts::PI))),
Operator::make_constant("π", F128(f128::new(std::f64::consts::PI))),
Operator::make_constant("E", F128(f128::new(std::f64::consts::E))),
Operator::make_constant("e", F128(f128::new(std::f64::consts::E))),
Operator::make_constant("TAU", F128(f128::new(std::f64::consts::TAU))),
Operator::make_constant("τ", F128(f128::new(std::f64::consts::TAU))),
]
}
}
#[derive(Clone, Debug)]
pub struct F256OpsFactory;
impl MakeOperators<f256::f256> for F256OpsFactory {
fn make<'a>() -> Vec<Operator<'a, f256::f256>> {
vec![
Operator::make_bin("^", BinOp { apply: |a, b| a.powf(&b), prio: 4, is_commutative: false }),
Operator::make_bin("*", BinOp { apply: |a, b| a * b, prio: 2, is_commutative: true }),
Operator::make_bin("/", BinOp { apply: |a, b| a / b, prio: 3, is_commutative: false }),
Operator::make_bin_unary("+", BinOp { apply: |a, b| a + b, prio: 0, is_commutative: true }, |a| a),
Operator::make_bin_unary("-", BinOp { apply: |a, b| a - b, prio: 1, is_commutative: false }, |a| -a),
Operator::make_unary("sin", |a| a.sin()),
Operator::make_unary("cos", |a| a.cos()),
Operator::make_unary("tan", |a| a.tan()),
Operator::make_unary("floor", |a| a.floor()),
Operator::make_unary("round", |a| a.round()),
Operator::make_unary("ceil", |a| a.ceil()),
Operator::make_unary("trunc", |a| a.trunc()),
Operator::make_unary("fract", |a| a.fract()),
Operator::make_unary("exp", |a| a.exp()),
Operator::make_unary("sqrt", |a| a.sqrt()),
Operator::make_unary("cbrt", |a| a.cbrt()),
Operator::make_unary("ln", |a| a.ln()),
Operator::make_unary("log2", |a| a.log2()),
Operator::make_unary("log10", |a| a.log10()),
Operator::make_unary("abs", |a| a.abs()),
Operator::make_unary("signum", |a| a.signum()),
Operator::make_constant("PI", f256::f256::from(std::f64::consts::PI)),
Operator::make_constant("π", f256::f256::from(std::f64::consts::PI)),
Operator::make_constant("E", f256::f256::from(std::f64::consts::E)),
Operator::make_constant("e", f256::f256::from(std::f64::consts::E)),
Operator::make_constant("TAU", f256::f256::from(std::f64::consts::TAU)),
Operator::make_constant("τ", f256::f256::from(std::f64::consts::TAU)),
]
}
}
static FIND_SCI_NOTATION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([0-9]+(?:\.[0-9]+)?)e([+-]?[0-9]+)").unwrap());
#[derive(Debug, poise::ChoiceParameter)]
pub enum Precision {
@@ -8,6 +114,18 @@ pub enum Precision {
Bits32,
#[name = "64-bit"]
Bits64,
#[name = "128-bit"]
Bits128,
#[name = "256-bit"]
Bits256,
}
#[derive(Debug, poise::ChoiceParameter)]
pub enum Mode {
#[name = "Floating Point"]
Float,
#[name = "Integer"]
Int,
}
/// Calculator
@@ -20,15 +138,31 @@ pub async fn calc(ctx: Context<'_>,
) -> Result<(), Error> {
debug!("{} has requested to calculate {}", ctx.author().name, expression);
let msg = "Calculating...".to_string();
trace_message(msg.clone(), ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
trace_message(&msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
let response_message = ctx.say(msg).await?;
let start_time = std::time::Instant::now();
trace!("Saving start time: {:?}", start_time);
let value = match precision {
Some(Precision::Bits32) => exmex::eval_str::<f32>(&expression).map_err(|err| err.to_string())?.to_string(),
Some(Precision::Bits64) => exmex::eval_str::<f64>(&expression).map_err(|err| err.to_string())?.to_string(),
None => exmex::eval_str::<f64>(&expression).map_err(|err| err.to_string())?.to_string(),
if expression.contains('=') {
let error_msg = format!("Invalid expression: {}\nYou are not allowed to have an equal sign in the expression.", expression);
debug!("Invalid expression detected: {}", expression);
trace_message(&error_msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
edit_response_message(&response_message, ctx, &error_msg, false).await?;
return Ok(());
}
let processed_expr : String = FIND_SCI_NOTATION_RE.replace_all(&expression, "$1 * 10^($2)").to_string();
trace!("Processed expression: {}", processed_expr);
let value = match evaluate_expression(processed_expr, &precision) {
Ok(v) => v,
Err(err_msg) => {
let error_msg = format!("Failed to parse or evaluate expression: `{}`", err_msg);
warn!("Failed to parse or evaluate expression: `{}` in guild {} channel {} by {}", err_msg, ctx.guild_id().unwrap().get(), ctx.channel_id().get(), ctx.author().name);
trace_message(&error_msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
edit_response_message(&response_message, ctx, &error_msg, false).await?;
return Ok(());
}
};
trace!("Result: {}", value);
let end_time = std::time::Instant::now();
trace!("Saving end time: {:?}", end_time);
let duration = end_time - start_time;
@@ -36,20 +170,78 @@ pub async fn calc(ctx: Context<'_>,
let precision_str = match precision {
Some(Precision::Bits32) => "32-bit".to_string(),
Some(Precision::Bits64) => "64-bit".to_string(),
Some(Precision::Bits128) => "128-bit".to_string(),
Some(Precision::Bits256) => "256-bit".to_string(),
None => "64-bit".to_string(),
};
let msg = format!("{} = {} \n-# Precision: {} Compute time : {:?}", expression, value, precision_str, duration);
trace_message(msg.clone(), ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
edit_response_message(&response_message, ctx, msg, false).await?;
debug!("Calculation perfomed for {} with result {} in {:?} by {}", expression, value, duration, ctx.author().name);
// Turn to scientifc notation if number has more than 30 digits
let value_string = if value.len() > 30 {
to_scientific_notation(value)
} else {
value
};
let msg = format!("{} = {} \n-# Precision: {} Compute time : {:?}", expression, value_string, precision_str, duration);
trace_message(&msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
edit_response_message(&response_message, ctx, &msg, false).await?;
debug!("Calculation perfomed for {} with result {} in {:?} by {}", expression, value_string, duration, ctx.author().name);
Ok(())
}
async fn edit_response_message<'a>(response_message: &poise::ReplyHandle<'a>, ctx: Context<'_>, content: String, silent: bool) -> Result<(), Error> {
if silent {
response_message.edit(ctx, poise::CreateReply::default().content(content).allowed_mentions(serenity::all::CreateAllowedMentions::new().empty_users())).await?;
} else {
response_message.edit(ctx, poise::CreateReply::default().content(content)).await?;
fn to_scientific_notation(value: String) -> String {
trace!("Converting {} to scientific notation", value);
if value.contains('e') || value.contains('E') {
trace!("Already in scientific notation");
return value;
}
Ok(())
let mut is_negative = false;
let mut val_str = value.as_str();
if val_str.starts_with('-') {
is_negative = true;
val_str = &val_str[1..];
}
let parts: Vec<&str> = val_str.split('.').collect();
let integer_part = parts[0];
let fractional_part = if parts.len() > 1 { parts[1] } else { "" };
if integer_part.len() <= 1 {
return value;
}
let first_digit = &integer_part[0..1];
let rest_integer = &integer_part[1..];
let exponent = integer_part.len() - 1;
let mut fractional_combined = format!("{}{}", rest_integer, fractional_part);
fractional_combined = fractional_combined.trim_end_matches('0').to_string();
let sign = if is_negative { "-" } else { "" };
if fractional_combined.is_empty() {
format!("{}{}e{}", sign, first_digit, exponent)
} else {
format!("{}{}.{}e{}", sign, first_digit, fractional_combined, exponent)
}
}
fn evaluate_expression(expr: String, precision: &Option<Precision>) -> Result<String, String> {
let value = match precision {
Some(Precision::Bits32) => exmex::eval_str::<f32>(&expr).map_err(|err| err.to_string())?.to_string(),
Some(Precision::Bits64) => exmex::eval_str::<f64>(&expr).map_err(|err| err.to_string())?.to_string(),
Some(Precision::Bits128) => {
let parsed_expr = exmex::FlatEx::<F128, F128OpsFactory>::parse(&expr).map_err(|err| err.to_string())?;
parsed_expr.eval(&[]).map_err(|err| err.to_string())?.to_string()
},
Some(Precision::Bits256) => {
let parsed_expr = exmex::FlatEx::<f256::f256, F256OpsFactory>::parse(&expr).map_err(|err| err.to_string())?;
parsed_expr.eval(&[]).map_err(|err| err.to_string())?.to_string()
},
None => exmex::eval_str::<f64>(&expr).map_err(|err| err.to_string())?.to_string(),
};
Ok(value)
}
+34
View File
@@ -0,0 +1,34 @@
use std::process::Command;
use crate::{Context, Error};
use tracing::{debug, warn, trace};
use crate::messaging::{trace_message, edit_response_message};
/// Run and output the contents of the fastfetch command on the machine running the bot
#[poise::command(slash_command, prefix_command)]
pub async fn fastfetch(ctx: Context<'_>) -> Result<(), Error> {
debug!("fastfetch command called by user {} in guild {}", ctx.author().id.get(), ctx.guild_id().unwrap().get());
let msg = "Processing...".to_string();
let res_msg = ctx.say(&msg).await?;
trace_message(&msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
let cmd = Command::new("fastfetch").args(&["--raw", "true", "--logo", "none"]).output();
match cmd {
Err(e) => {
warn!("Error executing fastfetch: {}", e);
let msg = format!("Error: {}", e);
edit_response_message(&res_msg, ctx, &msg, false).await?;
trace_message(&msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
Ok(())
}
Ok(output) => {
let output_str = output.stdout.into_iter().map(|c| c as char).collect::<String>();
let msg = format!("```ansi\n{}\n```", output_str);
edit_response_message(&res_msg, ctx, &msg, false).await?;
// Tracing the output would be a bad idea since it's really long and filled with ansi escape codes
// trace_message(msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
trace!("Saying fastfetch command output");
Ok(())
}
}
}
+183
View File
@@ -0,0 +1,183 @@
use crate::{Context, Error};
use tracing::{debug, trace};
use crate::messaging::{trace_message, edit_response_message};
/// Find the nth prime number (single threaded)
#[derive(Debug, poise::ChoiceParameter, Clone, Copy)]
pub enum Method {
#[name = "Sieve"]
Sieve,
#[name = "Trial and Error"]
TrialAndError,
}
#[poise::command(slash_command, prefix_command)]
pub async fn find_prime(
ctx: Context<'_>,
#[description = "Which nth prime number to find"]
n: u64,
#[description = "Method to use"]
method: Option<Method>,
#[description = "Custom timeout in seconds (upper limit of 60s)"]
timeout: Option<u64>,
) -> Result<(), Error> {
let guild_id_str = ctx.guild_id().map(|g| g.get().to_string()).unwrap_or_else(|| "DM".to_string());
debug!("Find prime command called by user {} in guild {}", ctx.author().id.get(), guild_id_str);
trace!("Primes requested for {} with method {:?}", n, method);
let msg = "Calculating...".to_string();
let res_msg = ctx.say(msg.clone()).await?;
trace_message(&msg, ctx.channel_id().to_string(), guild_id_str.clone()).await;
if n == 0 {
let msg = "Prime indices start at 1. Please provide a value greater than 0.".to_string();
edit_response_message(&res_msg, ctx, &msg, false).await?;
trace_message(&msg, ctx.channel_id().to_string(), guild_id_str).await;
return Ok(());
}
if timeout.is_some() && timeout.unwrap() > 60 {
let msg = "Timeout is too long. Maximum is 60 seconds.".to_string();
edit_response_message(&res_msg, ctx, &msg, false).await?;
trace_message(&msg, ctx.channel_id().to_string(), guild_id_str).await;
return Ok(());
}
let start_time = std::time::Instant::now();
let timeout_duration = std::time::Duration::from_secs(timeout.unwrap_or(20));
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::task::spawn_blocking(move || {
let result = match method {
Some(Method::Sieve) => sieve(n),
Some(Method::TrialAndError) => super3(n),
None => super3(n),
};
let _ = tx.send(result);
});
let method_name = match method {
Some(Method::Sieve) => "Sieve",
Some(Method::TrialAndError) => "TrialAndError",
None => "TrialAndError",
};
let result = match tokio::time::timeout(timeout_duration, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => {
let msg = "Calculation thread panicked or was dropped unexpectedly.".to_string();
edit_response_message(&res_msg, ctx, &msg, false).await?;
trace_message(&msg, ctx.channel_id().to_string(), guild_id_str).await;
return Ok(());
}
Err(_) => {
let msg = format!("Calculation timed out after {:?}", timeout_duration);
edit_response_message(&res_msg, ctx, &msg, false).await?;
trace_message(&msg, ctx.channel_id().to_string(), guild_id_str).await;
return Ok(());
}
};
let duration = start_time.elapsed();
let msg = format!("The {}th prime number is {}\n-# Calculation time: {:?}, Method: {:?}", n, result, duration, method_name);
edit_response_message(&res_msg, ctx, &msg, false).await?;
trace_message(&msg, ctx.channel_id().to_string(), guild_id_str).await;
Ok(())
}
// My best trial and error algorithm, originally designed to find as many primes as possible in 1s
pub fn super3(limit: u64) -> u64 {
if limit == 0 {
return 0;
}
let mut primes = Vec::new();
primes.push(2);
if limit == 1 {
return 2;
}
primes.push(3);
if limit == 2 {
return 3;
}
let mut current_multiplier = 1;
while (primes.len() as u64) < limit {
for offset in [-1_i64, 1] {
let base = 6 * current_multiplier;
let current_number = if offset < 0 {
base - 1
} else {
base + 1
};
let sqrt = (current_number as f64).sqrt().round() as u64;
let mut is_prime = true;
for &p in primes.iter().filter(|&&x| x > 3).take_while(|&&x| x <= sqrt) {
if current_number % p == 0 {
is_prime = false;
break;
}
}
if is_prime {
primes.push(current_number);
if (primes.len() as u64) == limit {
return current_number;
}
}
}
current_multiplier += 1;
}
*primes.last().unwrap()
}
fn sieve(n: u64) -> u64 {
if n == 0 {
panic!("Prime indices start at 1");
}
if n == 1 { return 2; }
if n == 2 { return 3; }
if n == 3 { return 5; }
if n == 4 { return 7; }
if n == 5 { return 11; }
let n_f = n as f64;
let ln_n = n_f.ln();
let limit = if n >= 688383 {
// Extremely tight bound for large n
(n_f * (ln_n + n_f.ln().ln() - 1.0 + (n_f.ln().ln() - 0.9385) / ln_n)) as usize
} else if n >= 6 {
// Safe bound for medium n
(n_f * (ln_n + n_f.ln().ln())) as usize
} else {
12 // Fallback for tiny numbers, though handled above
};
// Sieve of Eratosthenes
let mut is_prime = vec![true; limit + 1];
is_prime[0] = false;
is_prime[1] = false;
let mut count = 0;
for p in 2..=limit {
if is_prime[p] {
count += 1;
if count == n {
return p as u64;
}
// Safe overflow check for p * p
if let Some(mut i) = p.checked_mul(p) {
while i <= limit {
is_prime[i] = false;
i += p;
}
}
}
}
unreachable!("If we reach here, the upper bound calculation failed.");
}
+23
View File
@@ -0,0 +1,23 @@
use crate::{Context, Error};
use crate::messaging::{edit_response_message, trace_message};
use std::env;
use tracing::debug;
/// Get a link to the git repo
#[poise::command(slash_command, prefix_command)]
pub async fn git(ctx: Context<'_>) -> Result<(), Error> {
debug!("git command ran by {} in {}", ctx.author().name, ctx.channel_id().get());
let response = ctx.say("Processing...").await?;
let msg = format!("Git repo: https://git.ewenlau.net/ewenlau/tg-dev-srv-bot\nIf you'd like to contribute, contact <@1389325880853270569> to get an account.");
edit_response_message(&response, ctx, &msg, true).await?;
trace_message(&msg, ctx.channel_id().get().to_string(), ctx.guild_id().unwrap().get().to_string()).await;
Ok(())
}
+22 -30
View File
@@ -1,7 +1,8 @@
use crate::{Context, Error};
use crate::shared_functions::get_all_admin_users;
use crate::information_queries::get_all_admin_users;
use crate::messaging::edit_response_message;
use poise::serenity_prelude as sere;
use tracing::{error, warn, info, debug, trace};
use tracing::{error, info, debug, trace};
#[derive(Debug, poise::ChoiceParameter)]
pub enum AdminOperationType {
@@ -45,14 +46,14 @@ pub async fn manage_admins(ctx: Context<'_>,
if let Some(s_user) = user {
add_admin_user(ctx, &response_message, s_user).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a user".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide a user", false).await?;
}
}
Some(AdminOperationType::RemoveUser) => {
if let Some(s_user) = user {
remove_admin_user(ctx, &response_message, s_user).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a user".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide a user", false).await?;
}
}
Some(AdminOperationType::ListUsers) => {
@@ -62,21 +63,21 @@ pub async fn manage_admins(ctx: Context<'_>,
if let Some(s_role) = role {
add_admin_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide a role", false).await?;
}
}
Some(AdminOperationType::RemoveRole) => {
if let Some(s_role) = role {
remove_admin_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide a role", false).await?;
}
}
Some(AdminOperationType::ListRoles) => {
list_admin_roles(ctx, &response_message).await?;
}
None => {
edit_response_message(&response_message, ctx, "Please provide an operation".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide an operation", false).await?;
}
}
Ok(())
@@ -98,32 +99,23 @@ async fn add_admin_user<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, user
trace!("Executing add_admin_user for user {}", user.user.name);
if is_target_admin_user(ctx, &user).await? {
trace!("User {} is already an admin", user.user.name);
edit_response_message(msg, ctx, format!("{} is already an admin", user.user.name), false).await?;
edit_response_message(msg, ctx, &format!("{} is already an admin", user.user.name), false).await?;
return Ok(());
}
let query = sqlx::query!("INSERT INTO admin_users (guild_id, user_id) VALUES ($1, $2);", ctx.guild_id().unwrap().get() as i64, user.user.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Added user {} as admin in guild {}", user.user.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Added {} as admin", user.user.name), false).await?;
edit_response_message(msg, ctx, &format!("Added {} as admin", user.user.name), false).await?;
}
Err(e) => {
error!("Failed to add user {} as admin in guild {}: {:?}", user.user.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to add {} as admin: {}", user.user.name, e), false).await?;
edit_response_message(msg, ctx, &format!("Failed to add {} as admin: {}", user.user.name, e), false).await?;
}
}
Ok(())
}
async fn edit_response_message<'a>(response_message: &poise::ReplyHandle<'a>, ctx: Context<'_>, content: String, silent: bool) -> Result<(), Error> {
if silent {
response_message.edit(ctx, poise::CreateReply::default().content(content).allowed_mentions(serenity::all::CreateAllowedMentions::new().empty_users())).await?;
} else {
response_message.edit(ctx, poise::CreateReply::default().content(content)).await?;
}
Ok(())
}
async fn is_target_admin_user(ctx: Context<'_>, user: &sere::Member) -> Result<bool, Error> {
let admins = get_all_admin_users(ctx).await?;
for admin in admins {
@@ -139,18 +131,18 @@ async fn remove_admin_user<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, u
trace!("Executing remove_admin_user for user {}", user.user.name);
if !is_target_admin_user(ctx, &user).await? {
trace!("User {} is not an admin", user.user.name);
edit_response_message(msg, ctx, format!("{} is not an admin", user.user.name), false).await?;
edit_response_message(msg, ctx, &format!("{} is not an admin", user.user.name), false).await?;
return Ok(());
}
let query = sqlx::query!("DELETE FROM admin_users WHERE guild_id = $1 AND user_id = $2;", ctx.guild_id().unwrap().get() as i64, user.user.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Removed user {} as admin in guild {}", user.user.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Removed {} as admin", user.user.name), false).await?;
edit_response_message(msg, ctx, &format!("Removed {} as admin", user.user.name), false).await?;
}
Err(e) => {
error!("Failed to remove user {} as admin in guild {}: {:?}", user.user.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to remove {} as admin: {}", user.user.name, e), false).await?;
edit_response_message(msg, ctx, &format!("Failed to remove {} as admin: {}", user.user.name, e), false).await?;
}
}
Ok(())
@@ -159,7 +151,7 @@ async fn remove_admin_user<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, u
async fn list_admin_users<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>) -> Result<(), Error> {
let admins = get_all_admin_users(ctx).await?;
let admin_strings: Vec<String> = admins.iter().map(|user| format!("<@{}>", user.id.get())).collect();
edit_response_message(msg, ctx, format!("Admin users: {}", admin_strings.join(", ")), true).await?;
edit_response_message(msg, ctx, &format!("Admin users: {}", admin_strings.join(", ")), true).await?;
Ok(())
}
@@ -167,18 +159,18 @@ async fn add_admin_role<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, role
trace!("Executing add_admin_role for role {}", role.name);
if is_target_admin_role(ctx, &role).await? {
trace!("Role {} is already an admin role", role.name);
edit_response_message(msg, ctx, format!("{} is already an admin role", role.name), false).await?;
edit_response_message(msg, ctx, &format!("{} is already an admin role", role.name), false).await?;
return Ok(());
}
let query = sqlx::query!("INSERT INTO admin_roles (guild_id, role_id) VALUES ($1, $2);", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Added role {} as admin role in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Added {} as admin role", role.name), false).await?;
edit_response_message(msg, ctx, &format!("Added {} as admin role", role.name), false).await?;
}
Err(e) => {
error!("Failed to add role {} as admin role in guild {}: {:?}", role.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to add {} as admin role: {}", role.name, e), false).await?;
edit_response_message(msg, ctx, &format!("Failed to add {} as admin role: {}", role.name, e), false).await?;
}
}
Ok(())
@@ -188,18 +180,18 @@ async fn remove_admin_role<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, r
trace!("Executing remove_admin_role for role {}", role.name);
if !is_target_admin_role(ctx, &role).await? {
trace!("Role {} is not an admin role", role.name);
edit_response_message(msg, ctx, format!("{} is not an admin role", role.name), false).await?;
edit_response_message(msg, ctx, &format!("{} is not an admin role", role.name), false).await?;
return Ok(());
}
let query = sqlx::query!("DELETE FROM admin_roles WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Removed role {} as admin role in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Removed {} as admin role", role.name), false).await?;
edit_response_message(msg, ctx, &format!("Removed {} as admin role", role.name), false).await?;
}
Err(e) => {
error!("Failed to remove role {} as admin role in guild {}: {:?}", role.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to remove {} as admin role: {}", role.name, e), false).await?;
edit_response_message(msg, ctx, &format!("Failed to remove {} as admin role: {}", role.name, e), false).await?;
}
}
Ok(())
@@ -208,7 +200,7 @@ async fn remove_admin_role<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, r
async fn list_admin_roles<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>) -> Result<(), Error> {
let roles = sqlx::query!("SELECT role_id FROM admin_roles WHERE guild_id = $1", ctx.guild_id().unwrap().get() as i64).fetch_all(&ctx.data().pool).await?;
let role_strings: Vec<String> = roles.iter().map(|role| format!("<@&{}>", role.role_id)).collect();
edit_response_message(msg, ctx, format!("Admin roles: {}", role_strings.join(", ")), true).await?;
edit_response_message(msg, ctx, &format!("Admin roles: {}", role_strings.join(", ")), true).await?;
Ok(())
}
+26 -34
View File
@@ -1,7 +1,8 @@
use crate::{Context, Error};
use crate::shared_functions::get_all_admin_users;
use crate::information_queries::get_all_admin_users;
use crate::messaging::edit_response_message;
use poise::serenity_prelude as sere;
use tracing::{error, warn, info, debug, trace};
use tracing::{error, debug, trace, info, warn};
#[derive(Debug, poise::ChoiceParameter)]
pub enum AutoRoleOperationType {
@@ -28,7 +29,7 @@ pub async fn manage_auto_role(ctx: Context<'_>,
if !is_admin(ctx).await? {
debug!("User {} is not an admin, denying access", ctx.author().id.get());
edit_response_message(&response_message, ctx, "You are not allowed to run this command.".to_string(), false).await?;
edit_response_message(&response_message, ctx, "You are not allowed to run this command.", false).await?;
return Ok(());
}
@@ -37,28 +38,28 @@ pub async fn manage_auto_role(ctx: Context<'_>,
if let Some(s_role) = role {
enable_auto_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide a role", false).await?;
}
}
Some(AutoRoleOperationType::SetAutoRole) => {
if let Some(s_role) = role {
set_auto_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide a role", false).await?;
}
}
Some(AutoRoleOperationType::DisableAutoRole) => {
if let Some(s_role) = role {
disable_auto_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide a role", false).await?;
}
}
Some(AutoRoleOperationType::ShowAutoRole) => {
show_auto_role(ctx, &response_message).await?;
}
None => {
edit_response_message(&response_message, ctx, "Please provide an operation".to_string(), false).await?;
edit_response_message(&response_message, ctx, "Please provide an operation", false).await?;
}
}
Ok(())
@@ -80,29 +81,29 @@ async fn enable_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHandl
trace!("Executing enable_auto_role for role {}", role.name);
if !is_any_role_set_in_guild(ctx).await? {
trace!("No role is set in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "No role is set. Please set a role first.".to_string(), false).await?;
edit_response_message(response_message, ctx, "No role is set. Please set a role first.", false).await?;
return Ok(());
}
if !does_role_exist_in_guild(ctx, &role).await? {
trace!("Role {} does not exist in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "This role does not exist".to_string(), false).await?;
edit_response_message(response_message, ctx, "This role does not exist", false).await?;
return Ok(());
}
if is_auto_role_enabled(ctx, &role).await? {
trace!("Auto role {} is already enabled in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "Auto role is already enabled".to_string(), false).await?;
edit_response_message(response_message, ctx, "Auto role is already enabled", false).await?;
return Ok(());
}
let query = sqlx::query!("UPDATE auto_roles SET enabled = true WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Enabled auto role {} in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, format!("Enabled auto role: <@&{}>", role.id.get()), false).await?;
debug!("Enabled auto role {} in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, &format!("Enabled auto role: <@&{}>", role.id.get()), false).await?;
}
Err(e) => {
error!("Failed to enable {} as auto role in guild {}: {}", role.id.get(), ctx.guild_id().unwrap().get(), e);
edit_response_message(response_message, ctx, format!("Failed to enable {} as auto role: {}", role.id.get(), e), false).await?;
edit_response_message(response_message, ctx, &format!("Failed to enable {} as auto role: {}", role.id.get(), e), false).await?;
}
}
@@ -130,19 +131,19 @@ async fn set_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHandle<'
trace!("Executing set_auto_role for role {}", role.name);
if !does_role_exist_in_guild(ctx, &role).await? {
trace!("Role {} does not exist in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "This role does not exist".to_string(), false).await?;
edit_response_message(response_message, ctx, "This role does not exist", false).await?;
return Ok(());
}
let query = sqlx::query!("INSERT INTO auto_roles (guild_id, role_id, enabled) VALUES ($1, $2, false) ON CONFLICT (guild_id) DO UPDATE SET role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Set {} as auto role in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, format!("Set {} as auto role", role.name), false).await?;
debug!("Set {} as auto role in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, &format!("Set {} as auto role", role.name), false).await?;
}
Err(e) => {
error!("Failed to set {} as auto role in guild {}: {}", role.id.get(), ctx.guild_id().unwrap().get(), e);
edit_response_message(response_message, ctx, format!("Failed to set {} as auto role: {}", role.id.get(), e), false).await?;
edit_response_message(response_message, ctx, &format!("Failed to set {} as auto role: {}", role.id.get(), e), false).await?;
}
}
@@ -153,39 +154,30 @@ async fn disable_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHand
trace!("Executing disable_auto_role for role {}", role.name);
if !is_any_role_set_in_guild(ctx).await? {
trace!("No role is set in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "No auto role is set. Please set a role first.".to_string(), false).await?;
edit_response_message(response_message, ctx, "No auto role is set. Please set a role first.", false).await?;
return Ok(());
}
if !is_auto_role_enabled(ctx, &role).await? {
trace!("Auto role {} is already disabled in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "Auto role is already disabled".to_string(), false).await?;
edit_response_message(response_message, ctx, "Auto role is already disabled", false).await?;
return Ok(());
}
let query = sqlx::query!("UPDATE auto_roles SET enabled = false WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Disabled auto role {} in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, format!("Disabled auto role: {}", role.name), false).await?;
debug!("Disabled auto role {} in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, &format!("Disabled auto role: {}", role.name), false).await?;
}
Err(e) => {
error!("Failed to disable {} as auto role in guild {}: {}", role.id.get(), ctx.guild_id().unwrap().get(), e);
edit_response_message(response_message, ctx, format!("Failed to disable {} as auto role: {}", role.id.get(), e), false).await?;
edit_response_message(response_message, ctx, &format!("Failed to disable {} as auto role: {}", role.id.get(), e), false).await?;
}
}
Ok(())
}
async fn edit_response_message<'a>(response_message: &poise::ReplyHandle<'a>, ctx: Context<'_>, content: String, silent: bool) -> Result<(), Error> {
if silent {
response_message.edit(ctx, poise::CreateReply::default().content(content).allowed_mentions(serenity::all::CreateAllowedMentions::new().empty_users())).await?;
} else {
response_message.edit(ctx, poise::CreateReply::default().content(content)).await?;
}
Ok(())
}
async fn is_auto_role_enabled(ctx: Context<'_>, target_role: &sere::Role) -> Result<bool, Error> {
let query = sqlx::query!("SELECT enabled FROM auto_roles WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, target_role.id.get() as i64).fetch_optional(&ctx.data().pool).await?;
if query.is_none() {
@@ -206,16 +198,16 @@ async fn show_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHandle<
trace!("Executing show_auto_role");
if !is_any_role_set_in_guild(ctx).await? {
trace!("No role is set in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "No role has been set yet.".to_string(), false).await?;
edit_response_message(response_message, ctx, "No role has been set yet.", false).await?;
return Ok(());
}
if !is_any_auto_role_enabled(ctx).await? {
trace!("Auto role is disabled in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "Auto role is disabled.".to_string(), false).await?;
edit_response_message(response_message, ctx, "Auto role is disabled.", false).await?;
return Ok(());
}
let role_id = sqlx::query!("SELECT role_id FROM auto_roles WHERE guild_id = $1", ctx.guild_id().unwrap().get() as i64).fetch_optional(&ctx.data().pool).await?;
edit_response_message(response_message, ctx, format!("Currently, <@&{}> is set as auto role and is enabled.", role_id.unwrap().role_id), false).await?;
edit_response_message(response_message, ctx, &format!("Currently, <@&{}> is set as auto role and is enabled.", role_id.unwrap().role_id), false).await?;
Ok(())
}
+13 -2
View File
@@ -1,11 +1,22 @@
pub mod ping;
pub mod ai;
pub mod calc;
pub mod fastfetch;
pub mod find_prime;
pub mod manage_admins;
pub mod manage_auto_role;
pub mod ping;
pub mod speedy_pc;
pub mod version;
pub mod git;
pub use ping::ping;
pub use ai::answer;
pub use ai::prompt;
pub use calc::calc;
pub use fastfetch::fastfetch;
pub use find_prime::find_prime;
pub use manage_admins::manage_admins;
pub use manage_auto_role::manage_auto_role;
pub use ping::ping;
pub use speedy_pc::speedy_pc;
pub use version::version;
pub use git::git;
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::{Context, Error};
use poise::serenity_prelude as sere;
use tracing::{trace, debug};
use crate::shared_functions::trace_message;
use crate::messaging::trace_message;
/// Get the bot's current ping (back and forth)
#[poise::command(slash_command, prefix_command)]
@@ -17,7 +17,7 @@ pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
// We divide by 1000 since we get the time in microseconds, and then multiply by 2 to get the roundtrip time
// This is arguably not the best way to calculate ping, since it assumes perfect clock accuracy, but I'm lazy
let msg = format!("Current ping: {} ms", ping);
trace_message(msg.clone(), ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
trace_message(&msg, ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
ctx.say(msg).await?;
debug!("Ping command performed for user {} with ping {}", ctx.author().name, ping);
Ok(())
+205
View File
@@ -0,0 +1,205 @@
use crate::messaging::trace_message;
use crate::{Context, Error};
use chrono::{DateTime, Utc};
use ollama_rs::generation::completion::request::GenerationRequest;
use ollama_rs::models::create::CreateModelRequest;
use ollama_rs::Ollama;
use poise::CreateReply;
use std::env;
use std::time::Duration;
use tokio_stream::StreamExt;
use tracing::{debug, trace};
const MODEL_NAME: &str = "SpeedyPC";
/// Run a command on a system close to Speedy's PC performance
#[poise::command(slash_command, prefix_command)]
pub async fn speedy_pc(
ctx: Context<'_>,
#[description = "The command to run."] command: String,
) -> Result<(), Error> {
debug!(
"speedy_pc command called by user {} in guild {:?}",
ctx.author().id.get(),
ctx.guild_id()
);
let msg = format!("Running command: {:?}", command);
trace_message(
&msg,
ctx.channel_id().to_string(),
ctx.guild_id()
.map(|guild_id| guild_id.to_string())
.unwrap_or_else(|| "DM".to_string()),
)
.await;
let host = match env::var("TG_BOT_OLLAMA_HOST") {
Ok(host) => host,
Err(_) => {
ctx.say("Error: Expected an ollama url in the environment (`TG_BOT_OLLAMA_HOST`).")
.await?;
return Ok(());
}
};
let formatted_host = if !host.starts_with("http://") && !host.starts_with("https://") {
format!("http://{}", host)
} else {
host
};
let ollama = Ollama::builder().host(&formatted_host).port(11434).build();
let model_list = match ollama.list_local_models().await {
Ok(models) => models,
Err(error) => {
ctx.say(format!("Error: Failed to connect to simulation: {}", error))
.await?;
return Ok(());
}
};
let mut needs_create = true;
let yesterday = Utc::now() - chrono::Duration::days(1);
for model in &model_list {
if model.name.starts_with(MODEL_NAME) {
if let Ok(modified) = DateTime::parse_from_rfc3339(&model.modified_at) {
if modified.with_timezone(&Utc) > yesterday {
needs_create = false;
}
}
}
}
let reply = ctx
.say("Processing...")
.await?;
if needs_create {
reply.edit(
ctx,
CreateReply::default().content("Loading Speedy's PC..."),
)
.await?;
if let Err(error) = ollama
.create_model(
CreateModelRequest::new(MODEL_NAME.into())
.system(system_prompt().into())
.from_model("gemma4:e2b-it-qat".into()),
)
.await
{
reply
.edit(
ctx,
CreateReply::default().content(format!("Error loading Speedy's PC: {}", error)),
)
.await?;
return Ok(());
}
}
trace!("SpeedyPC command prompt: {}", command);
let mut stream = match ollama
.generate_stream(GenerationRequest::new(MODEL_NAME.into(), command).system(system_prompt()))
.await
{
Ok(stream) => stream,
Err(error) => {
reply
.edit(
ctx,
CreateReply::default().content(format!("Error during generation: {}", error)),
)
.await?;
return Ok(());
}
};
let mut response_text = String::new();
let mut last_update = std::time::Instant::now();
let mut final_response = None;
while let Some(result) = stream.next().await {
match result {
Ok(chunks) => {
for chunk in chunks {
response_text.push_str(&chunk.response);
if chunk.done {
final_response = Some(chunk);
}
}
if last_update.elapsed() >= Duration::from_secs(1) && !response_text.is_empty() {
let _ = reply
.edit(ctx, CreateReply::default().content(&response_text))
.await;
last_update = std::time::Instant::now();
}
}
Err(error) => {
let _ = reply
.edit(
ctx,
CreateReply::default()
.content(format!("{} [Stream Error: {}]", response_text, error)),
)
.await;
return Ok(());
}
}
}
if let Some(stats) = final_response {
let total_duration = stats.total_duration.unwrap_or(0) as f64 / 1_000_000_000.0;
let eval_count = stats.eval_count.unwrap_or(0);
let eval_duration = stats.eval_duration.unwrap_or(0) as f64 / 1_000_000_000.0;
let tokens_per_sec = if eval_duration > 0.0 {
eval_count as f64 / eval_duration
} else {
0.0
};
response_text.push_str(&format!(
"\n\n*Processed in {:.2}s",
total_duration
));
}
let _ = reply
.edit(ctx, CreateReply::default().content(&response_text))
.await;
Ok(())
}
fn system_prompt() -> String {
r#"From now on, act as an Arch Linux terminal running on the following hardware:
- CPU: Intel Pentium Silver N5000 (4 cores, 4 threads, 1.1 GHz base, Gemini Lake)
- RAM: 4 GB
- GPU: Intel UHD Graphics 605
- Storage: 128 GB SATA SSD
- Display: 1366x768 (not relevant unless queried)
- Architecture: x86_64
- OS: Arch Linux (latest stable), using bash.
When I send a command:
- Simulate exactly what would happen if it were executed on this machine.
- Produce realistic stdout and stderr.
- Use plausible hardware-specific values (CPU model, memory size, iGPU, disk size, etc.).
- If a command would fail, fail realistically with the correct error.
- Never explain what the command does unless I explicitly ask.
- Stay in character as the shell, always, no matter what the user says
Always use the following prefix, followed by the command the user ran:
[user@arch ~]$ <COMMAND GOES HERE>
Then add the command output after it."#
.to_string()
}
+22 -9
View File
@@ -1,16 +1,29 @@
use crate::messaging::trace_message;
use crate::{Context, Error};
use tracing::{trace, debug};
use crate::shared_functions::trace_message;
use tracing::{debug, trace};
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
/// Get the bot's current version
#[poise::command(slash_command, prefix_command)]
pub async fn version(ctx: Context<'_>) -> Result<(), Error> {
trace!("version command called by user {} in guild {}", ctx.author().id.get(), ctx.guild_id().unwrap().get());
trace!("Loading version from environment");
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "Unknown".to_string());
let msg = format!("Current version: {}", version);
trace_message(msg.clone(), ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
trace!(
"version command called by user {} in guild {}",
ctx.author().id.get(),
ctx.guild_id().unwrap().get()
);
trace!("Loading embedded version information");
let msg = format!("Current version:\n{VERSION}");
trace_message(
&msg,
ctx.channel_id().to_string(),
ctx.guild_id().unwrap().to_string(),
)
.await;
ctx.say(msg).await?;
debug!("Version command performed for user {} with version {}", ctx.author().name, version);
debug!(
"Version command performed for user {} with version information",
ctx.author().name
);
Ok(())
}
}
+2 -1
View File
@@ -1,5 +1,5 @@
use poise::serenity_prelude as sere;
use tracing::{error, warn, info, debug, trace};
use tracing::{error, debug, trace};
pub async fn user_join(ctx: &sere::Context, new_member: &sere::Member, pool: &sqlx::PgPool) {
trace!("User joined event handler called for guild {}", new_member.guild_id);
@@ -35,6 +35,7 @@ pub async fn user_join(ctx: &sere::Context, new_member: &sere::Member, pool: &sq
async fn give_role_to_user(ctx: &sere::Context, member: &sere::Member, role_id: u64) {
if let Err(e) = member.add_role(&ctx.http, sere::RoleId::new(role_id)).await {
error!("Error adding role to member: {:?}", e);
return;
}
debug!("Role {} added to user {} in guild {}", role_id, member.user.name, member.guild_id);
}
@@ -53,8 +53,4 @@ pub async fn get_all_admin_users(ctx: Context<'_>) -> Result<Vec<sere::User>, Er
}
Ok(admin_users)
}
pub async fn trace_message(msg: String, channel: String, guild: String) {
trace!("Saying \"{}\" in channel {} in guild {}", msg, channel, guild);
}
+21 -4
View File
@@ -1,11 +1,12 @@
use std::env;
use poise::serenity_prelude as sere;
use tracing::{info, error, debug, trace};
use tracing::{info, error, debug, trace, warn};
use tracing_subscriber::EnvFilter;
pub mod commands;
pub mod event_handler;
pub mod events;
pub mod shared_functions;
pub mod information_queries;
pub mod messaging;
pub struct Data {
pub pool: sqlx::PgPool,
@@ -47,6 +48,10 @@ async fn main() {
let token = env::var("TG_BOT_DISCORD_TOKEN").expect("Expected a token in the environment");
trace!("Token loaded");
if let Err(_) = env::var("TG_BOT_OLLAMA_HOST") {
warn!("TG_BOT_OLLAMA_HOST not set, AI commands will not work.");
}
info!("Starting bot...");
@@ -75,7 +80,19 @@ async fn main() {
event_handler: |ctx, event, framework, user_data| {
Box::pin(event_handler::event_handler(ctx, event, framework, user_data))
},
commands: vec![commands::ping(), commands::calc(), commands::manage_admins(), commands::manage_auto_role(), commands::version()],
on_error: |error| Box::pin(async move {
match error {
poise::FrameworkError::Command { error, ctx, .. } => {
error!("Error in command `{}`: {:?}", ctx.command().name, error);
}
error => {
if let Err(e) = poise::builtins::on_error(error).await {
error!("Error while handling error: {}", e);
}
}
}
}),
commands: vec![commands::ping(), commands::calc(), commands::manage_admins(), commands::manage_auto_role(), commands::version(), commands::fastfetch(), commands::find_prime(), commands::prompt(), commands::answer(), commands::speedy_pc(), commands::git()],
..Default::default()
})
.setup(|ctx, _ready, framework| {
@@ -97,4 +114,4 @@ async fn main() {
if let Err(why) = client.start().await {
error!("Client error: {why:?}");
}
}
}
+16
View File
@@ -0,0 +1,16 @@
use crate::{Context, Error};
use poise::serenity_prelude as sere;
use tracing::trace;
pub async fn trace_message(msg: &str, channel: String, guild: String) {
trace!("Saying \"{}\" in channel {} in guild {}", msg, channel, guild);
}
pub async fn edit_response_message<'a>(response_message: &poise::ReplyHandle<'a>, ctx: Context<'_>, content: &str, silent: bool) -> Result<(), Error> {
if silent {
response_message.edit(ctx, poise::CreateReply::default().content(content).allowed_mentions(serenity::all::CreateAllowedMentions::new().empty_users())).await?;
} else {
response_message.edit(ctx, poise::CreateReply::default().content(content)).await?;
}
Ok(())
}
-30
View File
@@ -1,30 +0,0 @@
#!/bin/bash
# AI WRITTEN
set -e
# Ensure we run from the script's directory
cd "$(dirname "$0")"
# Load environment variables if they exist
if [ -f .env ]; then
set -a
source .env
set +a
fi
echo "Wiping database volume and restarting containers..."
docker compose -f docker-compose-dev.yml down -v
docker compose -f docker-compose-dev.yml up -d
echo "Waiting for PostgreSQL to be ready..."
until [ "$(docker inspect -f '{{.State.Health.Status}}' tg-dev-srv-bot-postgres 2>/dev/null)" = "healthy" ]; do
printf "."
sleep 1
done
echo ""
echo "PostgreSQL is healthy!"
echo "Applying database schema from sqlschema.txt..."
docker compose -f docker-compose-dev.yml exec -T postgres psql -U "${TG_BOT_POSTGRES_USER:-postgres}" -d "${TG_BOT_POSTGRES_DB:-tg_dev_srv_bot}" -c "$(cat sqlschema.txt)"
echo "Database reset and schema applied successfully!"