Compare commits

..

3 Commits

Author SHA1 Message Date
Xyon 09ff09d48f
Deal with compiler warnings 2023-09-27 10:38:42 +01:00
Xyon 6458598026
Add custom response handlers 2023-09-27 10:37:53 +01:00
Xyon a8120c9466
Don't promote users if their rank is frozen 2023-09-26 22:58:25 +01:00
41 changed files with 708 additions and 2460 deletions

1
.envrc
View File

@ -1 +0,0 @@
use nix

View File

@ -15,46 +15,19 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Pre-seed known_hosts
run: mkdir -pv ~/.ssh && ssh-keyscan -t rsa badgey >> ~/.ssh/known_hosts
- name: Build (Release) - name: Build (Release)
run: cargo build --release --color=always run: cargo build --release --color=always
- name: Archive artifact
uses: actions/upload-artifact@v3
with:
name: badgey
path: target/release/badgey
deploy:
runs-on: rust
container:
options: --dns 172.16.255.254
strategy:
matrix:
bot: [ BADGEY, M5_COMPUTER ]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: badgey
- name: Pre-seed known_hosts
run: mkdir -pv ~/.ssh && ssh-keyscan -t rsa $BOT_SERVER_HOSTNAME >> ~/.ssh/known_hosts
env:
BOT_SERVER_HOSTNAME: ${{ vars[format('{0}_SERVER_HOSTNAME', matrix.bot)] }}
- uses: cschleiden/replace-tokens@v1.2 - uses: cschleiden/replace-tokens@v1.2
with: with:
files: config/production.badgey.json files: config/production.badgey.json
env: env:
BOT_NICKNAME: ${{ vars[format('{0}_BOT_NICKNAME', matrix.bot)] }}
BOT_IDENTIFIER: ${{ vars[format('{0}_SERVER_HOSTNAME', matrix.bot)] }}
LOG_CHANNEL_ID: ${{ vars[format('{0}_LOG_CHANNEL_ID', matrix.bot)] }}
POSTGRES_HOST: ${{ vars.POSTGRES_HOST }} POSTGRES_HOST: ${{ vars.POSTGRES_HOST }}
POSTGRES_USER: ${{ vars.POSTGRES_USER }} POSTGRES_USER: ${{ vars.POSTGRES_USER }}
POSTGRES_DATABASE_NAME: ${{ vars[format('{0}_POSTGRES_DATABASE_NAME', matrix.bot)] }} POSTGRES_DATABASE_NAME: ${{ vars.POSTGRES_DATABASE_NAME }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
WEATHER_API_KEY: ${{ secrets.WEATHER_API_KEY }}
DOGPICS_API_KEY: ${{ secrets.DOGPICS_API_KEY }}
CATPICS_API_KEY: ${{ secrets.CATPICS_API_KEY }}
- name: Seed SSH key for deploy - name: Seed SSH key for deploy
run: echo "${{ secrets.DEPLOY_KEY }}" | tr -d '\r' > ~/.ssh/id_rsa && chmod 0600 ~/.ssh/id_rsa run: echo "${{ secrets.DEPLOY_KEY }}" | tr -d '\r' > ~/.ssh/id_rsa && chmod 0600 ~/.ssh/id_rsa
- name: Deploy - name: Deploy
run: bash cicd/deploy.sh ${{ vars[format('{0}_SERVER_HOSTNAME', matrix.bot)] }} run: bash cicd/deploy.sh

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteTargetsManager">
<targets>
<target name="rust:latest" type="docker" uuid="abc6565f-f912-434a-ae04-68ceee732c3a">
<config>
<option name="targetPlatform">
<TargetPlatform />
</option>
<option name="buildNotPull" value="false" />
<option name="pullImageConfig">
<PullImageConfig>
<option name="tagToPull" value="rust:latest" />
</PullImageConfig>
</option>
</config>
<ContributedStateBase type="RsLanguageRuntime">
<config>
<option name="cargoPath" value="/usr/local/cargo/bin/cargo" />
<option name="cargoVersion" value="1.75.0 (1d8b05cdd 2023-11-20)" />
<option name="rustcPath" value="/usr/local/cargo/bin/rustc" />
<option name="rustcVersion" value="1.75.0 (82e1608df 2023-12-21)" />
</config>
</ContributedStateBase>
</target>
</targets>
</component>
</project>

8
.idea/sqldialects.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/2023-09-20-230922_extend userinfo to include XP/up.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/migrations/2023-09-26-215927_add custom responses table/up.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="SQLite" />
</component>
</project>

1834
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "badgey" name = "badgey"
version = "4.2.0" version = "3.1.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -20,22 +20,4 @@ manifold = { git = "https://code.orbiter-radio.uk/discord/manifold.git" }
poise = { version = "0.5.*", features = [ "cache" ] } poise = { version = "0.5.*", features = [ "cache" ] }
rand = { version = "0.8.5", features = [ "small_rng" ] } rand = { version = "0.8.5", features = [ "small_rng" ] }
regex = "1.9.5" regex = "1.9.5"
rust-i18n = "3.1.2"
to_markdown_table = "0.1.5"
tokio = { version = "1.16.1", features = ["sync", "macros", "rt-multi-thread"] } tokio = { version = "1.16.1", features = ["sync", "macros", "rt-multi-thread"] }
url = "2.5.2"
serde = { version = "1.0.210", features = ["derive"] }
reqwest = "0.12.9"
serde_json = "1.0.132"
[package.metadata.i18n]
# The available locales for your application, default: ["en"].
available-locales = ["en", "de"]
# The default locale, default: "en".
default-locale = "en"
# Path for your translations YAML file, default: "locales".
# This config for let `cargo i18n` command line tool know where to find your translations.
# You must keep this path same as the one you pass to method `rust_i18n::i18n!`.
load-path = "config/locales"

View File

@ -1,12 +1,7 @@
#!/bin/bash #!/bin/bash
bot=$(echo "$1" | tr '[:upper:]' '[:lower:]') ssh badgey@badgey sudo /usr/bin/systemctl stop badgey
rsync -avP target/release/badgey badgey@badgey:/srv/badgey/
echo "Running deploy for bot ${bot}" rsync -avP config badgey@badgey:/srv/badgey/
rsync -avP txt badgey@badgey:/srv/badgey/
ssh badgey@$bot sudo /usr/bin/systemctl stop $bot ssh badgey@badgey sudo /usr/bin/systemctl start badgey
rsync -avP badgey badgey@$bot:/srv/$bot/$bot
rsync -avP config badgey@$bot:/srv/$bot/
rsync -avP txt badgey@$bot:/srv/$bot/
ssh badgey@$bot chmod a+x /srv/$bot/$bot
ssh badgey@$bot sudo /usr/bin/systemctl start $bot

View File

@ -11,12 +11,12 @@
"channels": { "channels": {
"log": 648260641626390528 "log": 648260641626390528
}, },
"responses_file_path": "txt/responses", "responses_file_path": "txt/responses.txt",
"services": { "services": {
"weather": { "weather": {
"source_uri": "https://api.weatherapi.com/v1", "source_uri": "https://api.weatherapi.com/v1",
"cache_mode": "NoCache", "cache_mode": "NoCache",
"api_key": "" "api_key": "70aacb9af931438a957215406211210"
}, },
"frog_tips": { "frog_tips": {
"source_uri": "https://frog.tips/api/1/tips/", "source_uri": "https://frog.tips/api/1/tips/",

View File

@ -1,69 +0,0 @@
_version: 2
commands:
custom_response:
added:
de: "Benutzerdefinierte Antwort erfolgreich hinzugefügt"
en: "Custom response successfully added"
error:
de: "Benutzerdefinierte Antwort NICHT hinzugefügt, ein Fehler ist aufgetreten. Darüber sollten Sie sich wahrscheinlich bei Xyon beschweren. Der Fehler war %{error}"
en: "Custom response NOT added, an error happened. You should probably complain to Xyon about that. The error was %{error}"
marked_deleted:
de: "Benutzerdefinierte Antwort mit der ID %{id} erfolgreich als gelöscht markiert."
en: "Successfully marked custom response with ID %{response_id} as deleted."
marked_undeleted:
de: "Nicht gelöschte benutzerdefinierte Antwort mit der ID: %{response_id}"
en: "Undeleted custom response with ID: %{response_id}"
undelete_error_not_deleted:
de: "Die benutzerdefinierte Antwort mit der ID %{response_id} kann nicht wiederhergestellt werden, da sie nicht von vornherein gelöscht wurde."
en: "Can't undelete custom response with ID %{response_id} because it was not deleted in the first place."
list_user:
de: "Ich habe die folgenden benutzerdefinierten Antworten für %{user} gefunden: %{response_list}"
en: "I found the following custom responses for %{user}: %{response_list}"
ranks:
track_switch:
de: "Ich habe dich auf die %{new_track}-Leiter umgestellt. Du bist jetzt ein %{new_rank}. Ich hoffe, das hat sich gelohnt."
en: "I have switched you onto the %{new_track} rank track. You're now a %{new_rank}. I hope this was worth it."
failure:
de: "Sie befinden sich bereits auf der %(rank_track)-Leiter."
en: "You're already on the %(rank_track) rank track."
rank_freeze:
not_found:
de: "Diesen Rang konnte ich nicht finden um ihn dir zu setzen. Wahrscheinlich musst Du die Karriere-Leiter wechseln, oder es existiert einfach nicht."
en: "I couldn't find that rank as a rank I can give you. You might need to switch tracks, or it might just not exist."
success:
de: "Du bist nun für immer %{rank}. Viel Glück Harry!"
en: "Okay. I've frozen your rank at %{rank}. Enjoy!"
rank_unfreeze:
success:
de: "Dein Rang wurde enteist. Janeway wird das nicht erfreuen."
en: "Okay. I've unfrozen your rank. Good luck out there."
failure:
de: "Dein Rang war nicht eingefroren."
en: "Negative, your rank wasn't frozen."
rank:
current_rank:
de: "Du bist derzeit %{current_rank}. Du brauchst %{next_level_xp} mehr XP, um das nächste Level zu erreichen"
en: "You're currently %{current_rank}. You need %{next_level_xp} more XP to get to the next level"
max_rank:
de: "%{response}. Du bist bereits ganz oben angekommen! - Du kannst nicht weiter befördert werden!"
en: "%{response}. There are no more ranks for you - you can't be promoted any further!"
and_rank:
de: "%{response} und Rang!"
en: "%{response} and rank!"
and_rank_different_level:
de: "%{response} und du brauchst %{next_rank_xp} mehr, um den nächsten Rang zu erreichen. Viel Glück!"
en: "%{response} and you need %{next_rank_xp} more to get to the next rank. Good luck!"
models:
xp:
cooldown:
de: "Du hast eine Abklingzeit. Versuchen Sie es in %{timeuntil} Sekunden erneut."
en: "You are on cooldown. Try again in %{timeuntil} seconds."
unranked:
de: ohne Rangliste
en: unranked
misc:
version_string:
de: "Badgey Bot Version %{ver} erstellt um %{time} aus der Revision %{rev}"
en: "Badgey Bot version %{ver} built at %{time} from revision %{rev}"

View File

@ -1,8 +1,8 @@
{ {
"prefix": ".", "prefix": ".",
"nickname": "#{BOT_NICKNAME}#", "nickname": "Badgey",
"channels": { "channels": {
"log": "#{LOG_CHANNEL_ID}#" "log": 1143479696886087801
}, },
"database": { "database": {
"host": "#{POSTGRES_HOST}#", "host": "#{POSTGRES_HOST}#",
@ -11,12 +11,12 @@
"database_name": "#{POSTGRES_DATABASE_NAME}#", "database_name": "#{POSTGRES_DATABASE_NAME}#",
"port": 5432 "port": 5432
}, },
"responses_file_path": "txt/responses.#{BOT_IDENTIFIER}#", "responses_file_path": "txt/responses.txt",
"services": { "services": {
"weather": { "weather": {
"source_uri": "https://api.weatherapi.com/v1", "source_uri": "https://api.weatherapi.com/v1",
"cache_mode": "NoCache", "cache_mode": "NoCache",
"api_key": "#{WEATHER_API_KEY}#" "api_key": "70aacb9af931438a957215406211210"
}, },
"frog_tips": { "frog_tips": {
"source_uri": "https://frog.tips/api/1/tips/", "source_uri": "https://frog.tips/api/1/tips/",
@ -27,13 +27,13 @@
"source_uri": "https://api.thedogapi.com/v1/images/search?limit=100&order=RAND", "source_uri": "https://api.thedogapi.com/v1/images/search?limit=100&order=RAND",
"cache_name": "dog_pics", "cache_name": "dog_pics",
"cache_mode": "Cache", "cache_mode": "Cache",
"api_key": "#{DOGPICS_API_KEY}#" "api_key": "live_RRrRUsdmRIpKUefuwOwuAV1nab7Gt8GqyvIqPGCgLAbpLHGdyStbGj9Xc67inYMt"
}, },
"cat_pics": { "cat_pics": {
"source_uri": "https://api.thecatapi.com/v1/images/search?limit=100&order=RAND", "source_uri": "https://api.thecatapi.com/v1/images/search?limit=100&order=RAND",
"cache_name": "cat_pics", "cache_name": "cat_pics",
"cache_mode": "Cache", "cache_mode": "Cache",
"api_key": "#{CATPICS_API_KEY}#" "api_key": "live_nvupPQbrXjHy8jsZJ1stp72fzsRLR8jQby8IR3l9yMngqAU9gcTEV8RA0OOiK8zP"
}, },
"dad_jokes": { "dad_jokes": {
"source_uri": "https://icanhazdadjoke.com/search?limit=30", "source_uri": "https://icanhazdadjoke.com/search?limit=30",
@ -44,10 +44,6 @@
"source_uri": "https://api.nasa.gov/planetary/apod?api_key=NZfKclpoaO9HnvfvaCjeJ3csDecvIqNiABVw2YvN", "source_uri": "https://api.nasa.gov/planetary/apod?api_key=NZfKclpoaO9HnvfvaCjeJ3csDecvIqNiABVw2YvN",
"cache_name": "nasa_apod", "cache_name": "nasa_apod",
"cache_mode": "NoCache" "cache_mode": "NoCache"
},
"f1": {
"source_uri": "https://api.jolpi.ca/ergast/f1",
"cache_mode": "NoCache"
} }
} }
} }

View File

@ -1,6 +0,0 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@ -1,36 +0,0 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -1,2 +0,0 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS "custom_responses";

View File

@ -1,10 +0,0 @@
-- Your SQL goes here
CREATE TABLE IF NOT EXISTS "custom_responses" (
id SERIAL PRIMARY KEY,
trigger TEXT NOT NULL,
response TEXT NOT NULL,
added_by BIGINT NOT NULL,
added_on BIGINT NOT NULL,
added_for BIGINT NOT NULL REFERENCES userinfo(user_id),
deleted BOOLEAN
);

View File

@ -1,4 +0,0 @@
ALTER TABLE tracks
DROP COLUMN IF EXISTS xp_level_constant,
DROP COLUMN IF EXISTS xp_award_range_min,
DROP COLUMN IF EXISTS xp_award_range_max;

View File

@ -1,4 +0,0 @@
ALTER TABLE tracks
ADD COLUMN xp_level_constant FLOAT NOT NULL DEFAULT 0.25,
ADD COLUMN xp_award_range_min INT NOT NULL DEFAULT 1,
ADD COLUMN xp_award_range_max INT NOT NULL DEFAULT 30;

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS "quarantine_channels";

View File

@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS "quarantine_channels" (
id SERIAL PRIMARY KEY,
qc_role_id BIGINT NOT NULL,
qc_channel_id BIGINT NOT NULL
);

View File

@ -1,18 +0,0 @@
ALTER TABLE IF EXISTS "channels"
RENAME TO "quarantine_channels";
-- Make the quarantine channels table hold more generic channel info
ALTER TABLE IF EXISTS "quarantine_channels"
RENAME COLUMN "channel_id" TO "qc_channel_id";
-- Channels with this flag should retain old qc behaviour
ALTER TABLE IF EXISTS "quarantine_channels"
DROP COLUMN "is_quarantine_channel";
-- Setting flag to false allows channels to be excluded from XP even if not QC (though QC channels via the above flag imply this flag too)
ALTER TABLE IF EXISTS "quarantine_channels"
DROP COLUMN "is_valid_for_xp";
-- Ensure that the role ID can be null for non-qc channels
ALTER TABLE IF EXISTS "quarantine_channels"
ALTER COLUMN "qc_role_id" SET NOT NULL;

View File

@ -1,19 +0,0 @@
-- Make the quarantine channels table hold more generic channel info
ALTER TABLE IF EXISTS "quarantine_channels"
RENAME COLUMN "qc_channel_id" TO "channel_id";
-- Channels with this flag should retain old qc behaviour
ALTER TABLE IF EXISTS "quarantine_channels"
ADD COLUMN "is_quarantine_channel" BOOL NOT NULL DEFAULT FALSE;
-- Setting flag to false allows channels to be excluded from XP even if not QC (though QC channels via the above flag imply this flag too)
ALTER TABLE IF EXISTS "quarantine_channels"
ADD COLUMN "is_valid_for_xp" BOOL NOT NULL DEFAULT TRUE;
-- Ensure that the role ID can be null for non-qc channels
ALTER TABLE IF EXISTS "quarantine_channels"
ALTER COLUMN "qc_role_id" DROP NOT NULL;
-- Rename table to something more suitable
ALTER TABLE IF EXISTS "quarantine_channels"
RENAME TO "channels";

View File

@ -1,9 +0,0 @@
{ pkgs ? import <nixpkgs> {}}:
pkgs.mkShell {
buildInputs = with pkgs; [
openssl
postgresql
pkg-config
diesel-cli
];
}

View File

@ -2,8 +2,7 @@ use poise::serenity_prelude as serenity;
use manifold::error::{ManifoldError, ManifoldResult}; use manifold::error::{ManifoldError, ManifoldResult};
use manifold::{ManifoldContext, ManifoldData}; use manifold::{ManifoldContext, ManifoldData};
use poise::serenity_prelude::{CreateEmbed, Mentionable}; use poise::serenity_prelude::Mentionable;
use url::Url;
use crate::badgey::models::custom_response::{CustomResponse, CustomResponseInserter}; use crate::badgey::models::custom_response::{CustomResponse, CustomResponseInserter};
@ -12,8 +11,8 @@ async fn add_custom_response(ctx: ManifoldContext<'_>, target: serenity::User, t
let db = &ctx.data().database; let db = &ctx.data().database;
match CustomResponseInserter::new(trigger, response, target, ctx.author().clone()).insert(db) { match CustomResponseInserter::new(trigger, response, target, ctx.author().clone()).insert(db) {
Ok(_) => ctx.reply(t!("commands.custom_response.added")).await?, Ok(_) => ctx.reply("Custom response successfully added").await?,
Err(e) => ctx.reply(t!("commands.custom_response.added.error", error = e)).await?, Err(e) => ctx.reply(format!("Custom response NOT added, an error happened. You should probably complain to Xyon about that. The error was {}", e)).await?,
}; };
Ok(()) Ok(())
@ -28,7 +27,7 @@ async fn remove_custom_response(ctx: ManifoldContext<'_>, id: i32) -> ManifoldRe
response.deleted = Some(true); response.deleted = Some(true);
response.insert(db)?; response.insert(db)?;
ctx.reply(t!("commands.custom_response.marked_deleted", response_id=id)).await?; ctx.reply(format!("Successfully marked custom response with ID {} as deleted.", id)).await?;
Ok(()) Ok(())
} }
@ -42,9 +41,9 @@ async fn undelete_custom_response(ctx: ManifoldContext<'_>, id: i32) -> Manifold
if let Some(_) = response.deleted { if let Some(_) = response.deleted {
response.deleted = None; response.deleted = None;
response.insert(db)?; response.insert(db)?;
ctx.reply(t!("commands.custom_response.marked_undeleted", response_id=id)).await?; ctx.reply(format!("Undeleted custom response with ID: {}", &id)).await?;
} else { } else {
ctx.reply(t!("commands.custom_response.undelete_error_not_deleted", response_id=id)).await?; ctx.reply(format!("Can't undelete custom response with ID {} because it was not deleted in the first place.", &id)).await?;
} }
Ok(()) Ok(())
@ -53,35 +52,18 @@ async fn undelete_custom_response(ctx: ManifoldContext<'_>, id: i32) -> Manifold
#[poise::command(slash_command, prefix_command, required_permissions = "MODERATE_MEMBERS")] #[poise::command(slash_command, prefix_command, required_permissions = "MODERATE_MEMBERS")]
async fn list_custom_responses_for_user(ctx: ManifoldContext<'_>, target: serenity::User) -> ManifoldResult<()> { async fn list_custom_responses_for_user(ctx: ManifoldContext<'_>, target: serenity::User) -> ManifoldResult<()> {
let db = &ctx.data().database; let db = &ctx.data().database;
let userinfo = &ctx.data().user_info.lock().await;
let reply_handle = ctx.reply("Retrieving custom responses, please stand by...".to_string()).await?; let mut answer: String = "".to_string();
let mut pages = Vec::<CreateEmbed>::new();
let responses = CustomResponse::find_by_user(db, &target)?;
let total = responses.len();
responses.iter().enumerate().for_each(|(i, f)| { CustomResponse::find_by_user(db, &target)?.iter().for_each(|f| {
pages.push(CreateEmbed::default() answer.push_str(format!("{}{}", "\n", f.to_string()).as_str());
.title(format!("Custom Response {item} of {total} for user {target}", item=(i + 1), total=&total, target=target.name))
.description(format!("Added by {added_by} on <t:{added_on}:F> (<t:{added_on}:R>)", added_by=userinfo.get(&(f.added_by as u64)).unwrap().username, added_on=f.added_on))
.field("ID", format!("{id}", id=f.id), true)
.field("Deleted", format!("{deleted}", deleted=(if f.deleted.unwrap_or(false) {"yes"} else {"no"})), true)
.field("Trigger", format!("{trigger}", trigger=f.trigger), true)
.field("Response", format!("{response}", response=f.response), false)
.image(format!("{response}", response=Url::parse(&*f.response).unwrap_or("https://example.com".parse().unwrap())))
.to_owned()
)
}); });
if pages.len() == 0 { if answer.len() == 0 {
ctx.reply(format!("No custom responses found for {target}", target=target.mention())).await?; answer.push_str("\nNone found");
return Ok(())
} }
drop(db); ctx.reply(format!("I found the following custom responses for {}: {}", &target.mention(), answer)).await?;
drop(userinfo);
manifold::helpers::paginate(ctx, reply_handle, pages, 0).await?;
Ok(()) Ok(())
} }

View File

@ -1,62 +0,0 @@
use manifold::error::{ManifoldError, ManifoldResult};
use manifold::{ManifoldContext, ManifoldData};
use poise::serenity_prelude::CreateEmbed;
use to_markdown_table::MarkdownTable;
use crate::badgey::models::f1::{F1Client, StandingsListType};
#[poise::command(slash_command, prefix_command, subcommands("standings"))]
async fn f1(_ctx: ManifoldContext<'_>) -> ManifoldResult<()> {
Ok(())
}
#[derive(poise::ChoiceParameter)]
pub enum Championships {
Drivers,
Constructors,
}
#[poise::command(slash_command, prefix_command)]
async fn standings(ctx: ManifoldContext<'_>, championship: Championships) -> ManifoldResult<()> {
let config = &ctx.data().bot_config;
let f1_api_client = config.services.get("f1").ok_or_else(|| ManifoldError::from("No F1 API client available"))?;
let mut embed = CreateEmbed::default();
if let Some(standings) = f1_api_client.get_standings("current".to_string(), championship.to_string()).await? {
let _ = embed.title(format!("F1 {season} {championship} Championship Standings after {round} rounds:", season = standings.season, round = standings.round)).to_owned();
let mut leaderboard_headings = Vec::new();
let mut leaderboard_rows = Vec::new();
standings.standings_lists.iter().for_each(|f| {
match f {
StandingsListType::ConstructorStandings(c) => {
leaderboard_headings = vec!["Pos".to_string(), "Team".to_string(), "Pts".to_string()];
c.constructor_standings.iter().for_each(|r| {
leaderboard_rows.push(vec![r.position_text.clone(), r.constructor.name.clone(), r.points.clone()])
});
}
StandingsListType::DriversStandings(d) => {
leaderboard_headings = vec!["Pos".to_string(), "Driver".to_string(), "Team".to_string(), "Pts".to_string()];
d.driver_standings.iter().for_each(|r| {
leaderboard_rows.push(vec![r.position_text.clone(), format!("{first} {last}", first = r.driver.given_name.clone(), last = r.driver.family_name.clone()), r.constructors.first().unwrap().name.clone(), r.points.clone()])
});
}
}
let leaderboard_table = MarkdownTable::new(Some(leaderboard_headings.clone()), leaderboard_rows.clone()).unwrap();
embed.description(format!("```{table}```", table=leaderboard_table));
});
}
ctx.send(|m| {
m.embeds = vec![embed];
m
}).await?;
Ok(())
}
pub fn commands() -> [poise::Command<ManifoldData, ManifoldError>; 1] {
[f1()]
}

View File

@ -4,15 +4,11 @@ use poise::Command;
pub mod custom_responses; pub mod custom_responses;
pub mod ranks; pub mod ranks;
pub mod moderation;
pub mod f1;
pub fn collect_commands() -> Vec<Command<ManifoldData, ManifoldError>> { pub fn collect_commands() -> Vec<Command<ManifoldData, ManifoldError>> {
commands().into_iter() commands().into_iter()
.chain(custom_responses::commands()) .chain(custom_responses::commands())
.chain(ranks::commands()) .chain(ranks::commands())
.chain(moderation::commands())
.chain(f1::commands())
.collect() .collect()
} }

View File

@ -1,89 +0,0 @@
use built::chrono;
use manifold::error::{ManifoldError, ManifoldResult};
use manifold::{ManifoldContext, ManifoldData};
use poise::serenity_prelude as serenity;
use crate::badgey::models::quarantine_channel::Channel;
#[poise::command(slash_command, prefix_command, required_permissions = "MODERATE_MEMBERS")]
async fn void_user(_ctx: ManifoldContext<'_>, _target: serenity::User) -> ManifoldResult<()> {
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MODERATE_MEMBERS")]
async fn airlock_user(_ctx: ManifoldContext<'_>, _target: serenity::User) -> ManifoldResult<()> {
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MODERATE_MEMBERS")]
async fn unvoid_user(_ctx: ManifoldContext<'_>, _target: serenity::User) -> ManifoldResult<()> {
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MODERATE_MEMBERS")]
async fn record_chronicle(_ctx: ManifoldContext<'_>, _target: serenity::User) -> ManifoldResult<()> {
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MODERATE_MEMBERS", aliases("sr", "sl"))]
async fn security_record(ctx: ManifoldContext<'_>) -> ManifoldResult<()> {
let author = ctx.author().id;
let timestamp = chrono::Utc::now().timestamp();
let response_body = format!("```**__Server Security Report__**\n\n**Date:** <t:{timestamp}:f>\n**Moderator(s):** <@{author}>\n**Offending User(s):**\n\n**Description:**\n\n**Actions Taken:**```", timestamp = timestamp, author = author);
ctx.reply(response_body).await?;
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_GUILD")]
async fn add_quarantine_channel(ctx: ManifoldContext<'_>, channel: serenity::ChannelId, role: serenity::RoleId) -> ManifoldResult<()> {
let qc = Channel::new(Some(role.as_u64().clone() as i64), channel.as_u64().clone() as i64, true, false);
qc.insert(&ctx.data().database)?;
ctx.reply(format!("OK: Quarantine channel <#{channel}> defined with quarantine role <@{role}>", channel = channel, role = role)).await?;
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_GUILD")]
async fn remove_quarantine_channel(ctx: ManifoldContext<'_>, channel: serenity::ChannelId) -> ManifoldResult<()> {
let db = &ctx.data().database;
Channel::get_by_channel_id(db, channel.as_u64().clone() as i64)?.remove(db)?;
ctx.reply(format!("OK: Quarantine channel <#{channel}> is no longer defined.", channel = channel)).await?;
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_GUILD")]
async fn ignore_channel_for_xp(ctx: ManifoldContext<'_>, channel: serenity::ChannelId) -> ManifoldResult<()> {
let qc = Channel::new(None, channel.as_u64().clone() as i64, false, false);
qc.insert(&ctx.data().database)?;
ctx.reply(format!("OK: Channel <#{channel}> will no longer allow users to accrue XP.", channel = channel)).await?;
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_GUILD")]
async fn unignore_channel_for_xp(ctx: ManifoldContext<'_>, channel: serenity::ChannelId) -> ManifoldResult<()> {
let db = &ctx.data().database;
Channel::get_by_channel_id(db, channel.as_u64().clone() as i64)?.remove(db)?;
ctx.reply(format!("OK: Channel <#{channel}> will now permit users to accrue XP.", channel = channel)).await?;
Ok(())
}
#[poise::command(slash_command, prefix_command, required_permissions = "ADMINISTRATOR")]
async fn send_message(ctx: ManifoldContext<'_>, channel: serenity::ChannelId, message: String) -> ManifoldResult<()> {
channel.say(&ctx, message).await?;
ctx.reply(format!("Sent message to channel <#{channel}>", channel=channel)).await?;
Ok(())
}
pub fn commands() -> [poise::Command<ManifoldData, ManifoldError>; 10] {
[void_user(), unvoid_user(), airlock_user(), record_chronicle(), security_record(), add_quarantine_channel(), remove_quarantine_channel(), ignore_channel_for_xp(), unignore_channel_for_xp(), send_message()]
}

View File

@ -1,9 +1,8 @@
use built::chrono; use built::chrono;
use manifold::error::{ManifoldError, ManifoldResult}; use manifold::error::{ManifoldError, ManifoldResult};
use manifold::{ManifoldContext, ManifoldData}; use manifold::{ManifoldContext, ManifoldData};
use poise::serenity_prelude::{CreateEmbed, Mentionable, RoleId}; use poise::serenity_prelude::{Mentionable, RoleId};
use to_markdown_table::MarkdownTable; use crate::badgey::models::xp::{Rank, Track, Xp};
use crate::badgey::models::xp::{Leaderboard, Rank, Track, Xp};
#[poise::command(prefix_command, slash_command, user_cooldown = 86400)] #[poise::command(prefix_command, slash_command, user_cooldown = 86400)]
async fn switch_rank_track(ctx: ManifoldContext<'_>, #[description = "Track to switch to: officer or enlisted"] track: String) -> ManifoldResult<()> { async fn switch_rank_track(ctx: ManifoldContext<'_>, #[description = "Track to switch to: officer or enlisted"] track: String) -> ManifoldResult<()> {
@ -27,11 +26,11 @@ async fn switch_rank_track(ctx: ManifoldContext<'_>, #[description = "Track to s
xp.rank_track = parsed_track.track_id; xp.rank_track = parsed_track.track_id;
ctx.reply(t!("commands.ranks.track_switch", new_track = parsed_track.track_name, new_rank = new_level_rank.rank_name)).await?; ctx.reply(format!("I have switched you onto the {} rank track. You're now a {}. I hope this was worth it.", parsed_track.track_name, new_level_rank.rank_name)).await?;
xp.rank_track_last_changed = Some(chrono::Utc::now().timestamp()); xp.rank_track_last_changed = Some(chrono::Utc::now().timestamp());
xp.insert(db)?; xp.insert(db)?;
} else { } else {
ctx.reply(t!("commands.ranks.track_switch.failure", rank_track = parsed_track.track_name)).await?; ctx.reply(format!("You're already on the {} rank track.", parsed_track.track_name)).await?;
} }
Ok(()) Ok(())
@ -52,7 +51,7 @@ async fn freeze_rank(ctx: ManifoldContext<'_>, #[rest] #[description = "Rank to
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
error!("Rank lookup error: {}", e); error!("Rank lookup error: {}", e);
ctx.reply(t!("commands.ranks.rank_freeze.not_found")).await?; ctx.reply(format!("I couldn't find that rank as a rank I can give you. You might need to switch tracks, or it might just not exist.")).await?;
Err(e)? Err(e)?
} }
}; };
@ -68,7 +67,7 @@ async fn freeze_rank(ctx: ManifoldContext<'_>, #[rest] #[description = "Rank to
if let Some(mut member) = ctx.author_member().await { if let Some(mut member) = ctx.author_member().await {
member.to_mut().add_role(ctx, frozen_rank.role_id as u64).await?; member.to_mut().add_role(ctx, frozen_rank.role_id as u64).await?;
ctx.reply(t!("commands.ranks.rank_freeze.success", rank = frozen_rank.rank_name)).await?; ctx.reply(format!("Okay. I've frozen your rank at {}. Enjoy!", frozen_rank.rank_name)).await?;
} }
} else { } else {
@ -79,9 +78,9 @@ async fn freeze_rank(ctx: ManifoldContext<'_>, #[rest] #[description = "Rank to
} }
xp.freeze_rank = None; xp.freeze_rank = None;
ctx.reply(t!("commands.ranks.rank_unfreeze.success")).await?; ctx.reply(format!("Okay. I've unfrozen your rank. Good luck out there.")).await?;
} else { } else {
ctx.reply(t!("commands.ranks.rank_unfreeze.failure")).await?; ctx.reply(format!("Negative, your rank wasn't frozen.")).await?;
} }
} }
@ -103,16 +102,16 @@ async fn rank(ctx: ManifoldContext<'_>) -> ManifoldResult<()> {
let current_rank = Rank::get_rank_for_level(db, &xp.user_current_level, &xp.rank_track).unwrap_or(Rank::new()); let current_rank = Rank::get_rank_for_level(db, &xp.user_current_level, &xp.rank_track).unwrap_or(Rank::new());
let next_level_xp = xp.get_xp_to_next_level(&db); let next_level_xp = xp.get_xp_to_next_level();
let next_rank_xp = xp.get_xp_to_next_rank(&db).unwrap_or(0); let next_rank_xp = xp.get_xp_to_next_rank(&db).unwrap_or(0);
let mut response = t!("commands.ranks.rank.current_rank", current_rank = RoleId::from(current_rank.role_id as u64).mention(), next_level_xp = next_level_xp); let mut response = format!("You're currently {}. You need {} more XP to get to the next level", RoleId::from(current_rank.role_id as u64).mention(), next_level_xp);
if next_rank_xp == 0 { if next_rank_xp == 0 {
response = t!("commands.ranks.rank.max_rank", reponse = response); response = format!("{}. There are no more ranks for you - you can't be promoted any further!", response);
} else if next_rank_xp == next_level_xp { } else if next_rank_xp == next_level_xp {
response = t!("commands.ranks.rank.and_rank", response = response); response = format!("{} and rank!", response);
} else { } else {
response = t!("commands.ranks.rank.and_rank_different_level", response = response, next_rank_xp = next_rank_xp); response = format!("{} and you need {} more to rank up. Good luck!", response, next_rank_xp);
} }
ctx.reply(response).await?; ctx.reply(response).await?;
@ -120,57 +119,6 @@ async fn rank(ctx: ManifoldContext<'_>) -> ManifoldResult<()> {
Ok(()) Ok(())
} }
#[poise::command(prefix_command, slash_command)] pub fn commands() -> [poise::Command<ManifoldData, ManifoldError>; 3] {
async fn leaderboard(ctx: ManifoldContext<'_>) -> ManifoldResult<()> { [switch_rank_track(), freeze_rank(), rank()]
let reply_handle = ctx.reply("Retrieving leaderboard, please stand by...".to_string()).await?;
let entries_per_page = 20;
let mut pages = Vec::<CreateEmbed>::new();
let leaderboard = Leaderboard::get_leaderboard(&ctx.data().database)?;
let userinfo = ctx.data().user_info.lock().await;
let total = leaderboard.rows.len();
let pages_needed = (total / entries_per_page) + 1;
for i in 0..pages_needed {
let mut page = CreateEmbed::default()
.title(format!("XP Leaderboard Page {page} of {total_pages}", page=(i + 1), total_pages=&pages_needed)).to_owned();
let offset = i*entries_per_page;
let mut leaderboard_rows = Vec::new();
leaderboard.rows.iter().skip(offset).enumerate().for_each(|(j, f)| {
// cap at per-page limit
if j >= entries_per_page {
return;
}
let mut row = f.to_owned();
row.rank = (j + 1 + offset) as i32;
row.user_name = userinfo.get(&(row.uid as u64)).unwrap().username.clone();
leaderboard_rows.push(row);
});
let leaderboard_table = MarkdownTable::new(Some(vec!["Rank".to_string(), "User".to_string(), "XP".to_string()]), leaderboard_rows)?;
page.description(format!("```{table}```", table=leaderboard_table));
pages.push(page);
}
if pages.len() == 0 {
ctx.reply("No leaderboard entries yet!".to_string()).await?;
return Ok(())
}
// Release our mutex lock on this object before the paginate call to avoid holding a lock on it for the full paginate interaction listener timeout duration
drop(userinfo);
manifold::helpers::paginate(ctx, reply_handle, pages, 0).await?;
Ok(())
}
pub fn commands() -> [poise::Command<ManifoldData, ManifoldError>; 4] {
[switch_rank_track(), freeze_rank(), rank(), leaderboard()]
} }

View File

@ -1,12 +1,15 @@
use built::chrono;
use manifold::error::{ManifoldError, ManifoldResult}; use manifold::error::{ManifoldError, ManifoldResult};
use manifold::events::EventHandler; use manifold::events::{Handler, EventHandler};
use manifold::ManifoldData; use manifold::ManifoldData;
use poise::{async_trait, FrameworkContext, Event}; use poise::{async_trait, FrameworkContext, Event};
use poise::serenity_prelude::{Context, Member, Message}; use poise::serenity_prelude::{Context, Mentionable, Message, RoleId};
use crate::badgey::handlers; use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use crate::badgey::models::custom_response::CustomResponse; use crate::badgey::models::custom_response::CustomResponse;
use crate::badgey::models::xp::Xp; use crate::badgey::models::xp::{Rank, Xp};
pub struct BadgeyHandler { pub struct BadgeyHandler {
@ -15,8 +18,9 @@ pub struct BadgeyHandler {
#[async_trait] #[async_trait]
impl EventHandler for BadgeyHandler { impl EventHandler for BadgeyHandler {
async fn listen(ctx: &Context, framework_ctx: FrameworkContext<'_, ManifoldData, ManifoldError>, event: &Event<'_>) -> ManifoldResult<()> { async fn listen(ctx: &Context, framework_ctx: FrameworkContext<'_, ManifoldData, ManifoldError>, event: &Event<'_>) -> ManifoldResult<()> {
Handler::listen(ctx, framework_ctx, event).await?;
match event { match event {
Event::GuildMemberUpdate { old_if_available, new } => BadgeyHandler::guild_member_update(&ctx, &framework_ctx, old_if_available, new).await,
Event::Message { new_message } => BadgeyHandler::message(&ctx, &framework_ctx, new_message).await, Event::Message { new_message } => BadgeyHandler::message(&ctx, &framework_ctx, new_message).await,
_ => Ok(()) _ => Ok(())
} }
@ -24,27 +28,73 @@ impl EventHandler for BadgeyHandler {
} }
impl BadgeyHandler { impl BadgeyHandler {
pub async fn guild_member_update (ctx: &Context, fctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, old_if_available: &Option<Member>, new: &Member) -> ManifoldResult<()> {
handlers::airlock::airlock_handler(ctx, fctx, old_if_available, new).await?;
Ok(())
}
pub async fn message(ctx: &Context, fctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, msg: &Message) -> ManifoldResult<()> { pub async fn message(ctx: &Context, fctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, msg: &Message) -> ManifoldResult<()> {
// Ignore our own messages
if msg.author.id == ctx.http.get_current_user().await?.id || msg.content.starts_with(&fctx.user_data().await.bot_config.prefix) { if msg.author.id == ctx.http.get_current_user().await?.id || msg.content.starts_with(&fctx.user_data().await.bot_config.prefix) {
return Ok(()) return Ok(())
} }
let db = &fctx.user_data().await.database; let db = &fctx.user_data().await.database;
if let Ok(r) = CustomResponse::test_content(db, &msg.content_safe(&ctx.cache), &"!".to_string()) { if let Ok(r) = CustomResponse::test_content(db, &msg.content_safe(&ctx.cache), &"!".to_string()) {
msg.channel_id.say(ctx, r.response).await?; msg.channel_id.say(ctx, r.response).await?;
} }
Xp::award(ctx, fctx, msg, &db).await?; if fctx.user_data().await.user_info.lock().await.get_mut(&msg.author.id.as_u64()).is_none() {
debug!("Tried to add XP to a user we don't know about, aborting.");
return Ok(())
}
let mut rng = SmallRng::from_entropy();
let xp_reward = rng.gen_range(1..30).clone();
drop(rng);
let user_id_i64 = msg.author.id.as_u64().clone() as i64;
let mut xp = match Xp::get(db, &user_id_i64) {
Ok(x) => x,
Err(_) => Xp::new(&user_id_i64)
};
let valid = match xp.last_given_xp {
Some(t) => {
if (chrono::Utc::now().timestamp() - 60) > t {
true
} else {
false
}
},
None => true
};
if valid {
xp.last_given_xp = Some(chrono::Utc::now().timestamp());
xp.xp_value += &xp_reward;
let calculated_level = xp.get_level_from_xp();
if xp.freeze_rank.is_some() {
xp.user_current_level = calculated_level.clone();
xp.insert(db)?;
return Ok(())
}
if (xp.user_current_level != calculated_level) || xp.user_current_level == 1 {
if let Some(guild) = msg.guild(&ctx.cache) {
let mut member = guild.member(&ctx, msg.author.id).await?;
let old_role_id = RoleId::from(Rank::get_rank_for_level(db, &xp.user_current_level, &xp.rank_track).unwrap_or(Rank::new()).role_id as u64);
xp.user_current_level = calculated_level;
let new_role = RoleId::from(Rank::get_rank_for_level(db, &calculated_level, &xp.rank_track)?.role_id as u64);
if (new_role != old_role_id || xp.user_current_level == 1) && msg.author.has_role(ctx, guild.id, new_role).await? == false {
member.remove_role(ctx, old_role_id).await?;
member.add_role(ctx, new_role).await?;
msg.channel_id.say(ctx, format!("{} is now a {}", msg.author.mention(), new_role.mention())).await?;
}
} else {
error!("Needed to add level to user, but couldn't get the member from the guild!");
}
}
xp.insert(db)?;
}
Ok(()) Ok(())
} }

View File

@ -1,36 +0,0 @@
use manifold::error::{ManifoldError, ManifoldResult};
use manifold::ManifoldData;
use poise::FrameworkContext;
use poise::serenity_prelude::{ChannelId, Context, Member, Mentionable};
use crate::badgey::models::quarantine_channel::Channel;
pub async fn airlock_handler(ctx: &Context, fctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, old: &Option<Member>, new: &Member) -> ManifoldResult<()> {
let db = &fctx.user_data.database;
// Is this user quarantined?
for role in new.roles.iter() {
if let Ok(channel) = Channel::get_by_qc_role_id(db, role.as_u64().clone() as i64) {
// Was this user JUST added to a quarantine channel?
if let Some(m) = old {
for role in m.roles.iter() {
if let Ok(_) = Channel::get_by_qc_role_id(db, role.as_u64().clone() as i64) {
// User already had quarantine role, return early
return Ok(())
}
}
let target_channel: ChannelId = ChannelId::from(channel.channel_id as u64);
// User was just added to a quarantine channel. Post the initial ping message.
target_channel.say(ctx, format!("{mention}, please respond to this message", mention = new.mention())).await?;
target_channel.await_reply(ctx).author_id(new.user.id).await;
target_channel.say(ctx, format!("{mention}, you have been brought to a private area by a server staff member. This does not necessarily mean you have broken the server rules; just that a staff member wishes to discuss something with you in a private and recorded environemnt.\n\nPlease note that the message history of this channel is not visible to you, and if your discord client loses focus on this channel it may then appear empty to you when you return. All message sent will remain visible to staff.\n\nPlease wait - a staff member should arrive shortly to explain why you are here.", mention = new.mention())).await?;
return Ok(())
}
}
}
Ok(())
}

View File

@ -1 +0,0 @@
pub mod airlock;

View File

@ -9,16 +9,13 @@ mod commands;
mod events; mod events;
mod models; mod models;
mod schema; mod schema;
mod handlers;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[tokio::main] #[tokio::main]
pub async fn run(arguments: ArgMatches) { pub async fn run(arguments: ArgMatches) {
rust_i18n::set_locale(arguments.get_one::<String>("locale").unwrap());
let git_info: String = built_info::GIT_VERSION.unwrap_or("unknown").to_string(); let git_info: String = built_info::GIT_VERSION.unwrap_or("unknown").to_string();
let version_string = t!("misc.version_string", ver=built_info::PKG_VERSION, time=built_info::BUILT_TIME_UTC, rev=git_info).to_string(); let version_string = format!("Badgey Bot version {ver} built at {time} from revision {rev}", ver=built_info::PKG_VERSION, time=built_info::BUILT_TIME_UTC, rev=git_info);
let client = match manifold::prepare_client::<BadgeyHandler>(arguments, GatewayIntents::all(), commands::collect_commands(), version_string, MIGRATIONS).await { let client = match manifold::prepare_client::<BadgeyHandler>(arguments, GatewayIntents::all(), commands::collect_commands(), version_string, MIGRATIONS).await {
Ok(c) => c, Ok(c) => c,

View File

@ -1,5 +1,5 @@
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use built::chrono::{DateTime, Utc}; use built::chrono::{NaiveDateTime, Utc};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::insert_into; use diesel::insert_into;
use regex::Regex; use regex::Regex;
@ -10,8 +10,6 @@ use manifold::error::{ManifoldError, ManifoldResult};
use manifold::models::user::UserInfo; use manifold::models::user::UserInfo;
use manifold::schema::userinfo; use manifold::schema::userinfo;
define_sql_function!(fn random() -> Text);
use crate::badgey::schema::*; use crate::badgey::schema::*;
#[derive(Queryable, Selectable, Identifiable, Insertable, AsChangeset, Associations, Debug, PartialEq, Clone)] #[derive(Queryable, Selectable, Identifiable, Insertable, AsChangeset, Associations, Debug, PartialEq, Clone)]
@ -82,7 +80,6 @@ impl CustomResponse {
Ok(custom_responses::table Ok(custom_responses::table
.filter(custom_responses::trigger.ilike(needle)) .filter(custom_responses::trigger.ilike(needle))
.filter(custom_responses::deleted.is_null()) .filter(custom_responses::deleted.is_null())
.order_by(random())
.limit(1) .limit(1)
.select(CustomResponse::as_select()) .select(CustomResponse::as_select())
.get_result(&mut conn.get()?)?) .get_result(&mut conn.get()?)?)
@ -112,6 +109,6 @@ impl CustomResponse {
impl Display for CustomResponse { impl Display for CustomResponse {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "ID: {}, trigger: {}, response: {}, added by: {}, added on: {}, deleted: {}", self.id, self.trigger, self.response, serenity::UserId::from(self.added_by as u64), DateTime::from_timestamp(self.added_on, 0).unwrap(), self.deleted.unwrap_or(false)) write!(f, "ID: {}, trigger: {}, response: {}, added by: {}, added on: {}, deleted: {}", self.id, self.trigger, self.response, serenity::UserId::from(self.added_by as u64), NaiveDateTime::from_timestamp_opt(self.added_on, 0).unwrap(), self.deleted.unwrap_or(false))
} }
} }

View File

@ -1,230 +0,0 @@
use manifold::error::ManifoldResult;
use manifold::models::fueltank::FuelTank;
use reqwest::header::CONTENT_TYPE;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CircuitLocation {
pub lat: String,
pub long: String,
pub locality: String,
pub country: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Circuit {
pub circuit_id: String,
pub url: String,
pub circuit_name: String,
#[serde(alias = "Location")]
pub location: CircuitLocation,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RaceSession {
pub date: Option<String>,
pub time: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Race {
pub season: String,
pub round: String,
pub url: String,
pub race_name: String,
#[serde(alias = "Circuit")]
pub circuit: Circuit,
pub date: String,
pub time: Option<String>,
pub first_practice: Option<RaceSession>,
pub second_practice: Option<RaceSession>,
pub third_practice: Option<RaceSession>,
pub qualifying: Option<RaceSession>,
pub sprint: Option<RaceSession>,
pub sprint_qualifying: Option<RaceSession>,
pub sprint_shootout: Option<RaceSession>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PitStop {
pub driver_id: String,
pub lap: Option<String>,
pub stop: Option<String>,
pub time: Option<String>,
pub duration: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Qualifying {
pub number: String,
pub position: Option<String>,
pub driver: Driver,
pub constructor: Constructor,
pub q1: Option<String>,
pub q2: Option<String>,
pub q3: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Timing {
pub driver_id: String,
pub position: String,
pub time: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Lap {
pub number: String,
pub timings: Vec<Timing>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RaceResult {
pub number: String,
pub position: String,
pub position_text: String,
pub points: String,
pub driver: Driver,
pub constructor: Option<Constructor>,
pub grid: Option<String>,
pub laps: Option<String>,
pub status: Option<String>,
pub fastest_lap: Option<Lap>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Constructor {
pub constructor_id: Option<String>,
pub url: Option<String>,
pub name: String,
pub nationality: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ConstructorStandings {
pub position: Option<String>,
pub position_text: String,
pub points: String,
pub wins: String,
#[serde(alias = "Constructor")]
pub constructor: Constructor,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Driver {
pub driver_id: String,
pub permanent_number: Option<String>,
pub code: Option<String>,
pub url: Option<String>,
pub given_name: String,
pub family_name: String,
pub date_of_birth: Option<String>,
pub nationality: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DriverStandings {
pub position: Option<String>,
pub position_text: String,
pub points: String,
pub wins: String,
#[serde(alias = "Driver")]
pub driver: Driver,
#[serde(alias = "Constructors")]
pub constructors: Vec<Constructor>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DriverStandingsList {
pub season: String,
pub round: String,
#[serde(alias = "DriverStandings")]
pub driver_standings: Vec<DriverStandings>
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ConstructorStandingsList {
pub season: String,
pub round: String,
#[serde(alias = "ConstructorStandings")]
pub constructor_standings: Vec<ConstructorStandings>
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged, rename_all_fields = "camelCase")]
pub enum StandingsListType {
ConstructorStandings(ConstructorStandingsList),
DriversStandings(DriverStandingsList)
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct StandingsTable {
pub season: String,
pub round: String,
#[serde(alias = "StandingsLists")]
pub standings_lists: Vec<StandingsListType>
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Season {
pub season: String,
pub url: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct F1APIResponse {
#[serde(alias = "MRData")]
mrdata: MRData,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MRData {
pub series: String,
pub xmlns: String,
pub url: String,
pub limit: String,
pub offset: String,
pub total: String,
#[serde(alias = "StandingsTable")]
pub standings_table: Option<StandingsTable>
}
pub type F1 = FuelTank;
pub trait F1Client {
async fn get_standings(&self, season: String, championship: String) -> ManifoldResult<Option<StandingsTable>>;
}
impl F1Client for F1 {
async fn get_standings(&self, season: String, championship: String) -> ManifoldResult<Option<StandingsTable>> {
let base_url = format!("{base}/{season}/{championship}tandings/", base = self.source_uri, season = season, championship = championship.to_lowercase());
let client = reqwest::Client::new();
let response = client.get(base_url)
.header(CONTENT_TYPE, "application/json")
.send()
.await?
.text()
.await?;
let decoded_result: F1APIResponse = serde_json::from_str(&*response)?;
Ok(decoded_result.mrdata.standings_table)
}
}

View File

@ -1,4 +1,2 @@
pub mod custom_response; pub mod custom_response;
pub mod xp; pub mod xp;
pub mod quarantine_channel;
pub mod f1;

View File

@ -1,59 +0,0 @@
use diesel::{insert_into, Identifiable, Insertable, QueryDsl, Queryable, RunQueryDsl, Selectable};
use diesel::dsl::delete;
use diesel::prelude::*;
use manifold::Db;
use manifold::error::{ManifoldError, ManifoldResult};
use crate::badgey::schema::channels as channel_table;
use crate::badgey::schema::*;
#[derive(Queryable, Selectable, Identifiable, Insertable, Debug, Clone)]
#[diesel(table_name = channels)]
pub struct Channel {
#[diesel(deserialize_as = i32)]
pub id: Option<i32>,
pub qc_role_id: Option<i64>,
pub channel_id: i64,
pub is_quarantine_channel: bool,
pub is_valid_for_xp: bool,
}
impl Channel {
pub fn new(qc_role_id: Option<i64>, channel_id: i64, is_quarantine_channel: bool, is_valid_for_xp: bool) -> Self {
Channel {
id: None,
qc_role_id,
channel_id,
is_quarantine_channel,
is_valid_for_xp,
}
}
pub fn insert(&self, conn: &Db) -> ManifoldResult<usize> {
insert_into(channel_table::dsl::channels)
.values(self)
.execute(&mut conn.get()?)
.map_err(|e| ManifoldError::from(e))
}
pub fn remove(&self, conn: &Db) -> ManifoldResult<usize> {
Ok(delete(channel_table::dsl::channels)
.filter(channel_table::qc_role_id.eq(self.qc_role_id))
.execute(&mut conn.get()?)?)
}
pub fn get_by_channel_id(conn: &Db, needle: i64) -> ManifoldResult<Self> {
Ok(channel_table::dsl::channels
.filter(channel_table::channel_id.eq(needle))
.limit(1)
.select(Channel::as_select())
.get_result(&mut conn.get()?)?)
}
pub fn get_by_qc_role_id(conn: &Db, needle: i64) -> ManifoldResult<Self> {
Ok(channel_table::dsl::channels
.filter(channel_table::qc_role_id.eq(needle))
.limit(1)
.select(Channel::as_select())
.get_result(&mut conn.get()?)?)
}
}

View File

@ -1,17 +1,11 @@
use built::chrono; use built::chrono;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::insert_into; use diesel::insert_into;
use rand::rngs::SmallRng; use manifold::Db;
use rand::{Rng, SeedableRng};
use to_markdown_table::{TableRow};
use manifold::{Db, ManifoldData};
use manifold::error::{ManifoldError, ManifoldResult}; use manifold::error::{ManifoldError, ManifoldResult};
use manifold::models::user::UserInfo; use manifold::models::user::UserInfo;
use manifold::schema::userinfo; use manifold::schema::userinfo;
use poise::FrameworkContext;
use poise::serenity_prelude::{Context, Mentionable, Message, RoleId};
use crate::badgey::models::quarantine_channel::Channel;
use crate::badgey::schema::xp as xp_table; use crate::badgey::schema::xp as xp_table;
use crate::badgey::schema::*; use crate::badgey::schema::*;
@ -28,7 +22,7 @@ pub struct Xp {
pub rank_track: i64, pub rank_track: i64,
pub rank_track_last_changed: Option<i64>, pub rank_track_last_changed: Option<i64>,
pub freeze_rank: Option<i64>, pub freeze_rank: Option<i64>,
pub freeze_rank_last_changed: Option<i64>, pub freeze_rank_last_changed: Option<i64>
} }
#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] #[derive(Queryable, Selectable, Identifiable, Debug, Clone)]
@ -40,55 +34,11 @@ pub struct Rank {
pub rank_name: String, pub rank_name: String,
} }
#[derive(Queryable, Selectable, Identifiable, Debug, Clone, Default)] #[derive(Queryable, Selectable, Identifiable, Debug, Clone)]
#[diesel(primary_key(track_id))] #[diesel(primary_key(track_id))]
pub struct Track { pub struct Track {
pub track_id: i64, pub track_id: i64,
pub track_name: String, pub track_name: String,
pub xp_level_constant: f64,
pub xp_award_range_min: i32,
pub xp_award_range_max: i32,
}
pub struct Leaderboard {
pub rows: Vec<LeaderboardRow>
}
#[derive(Clone)]
pub struct LeaderboardRow {
pub rank: i32,
pub uid: i64,
pub user_name: String,
pub value: i64,
}
impl Into<TableRow> for LeaderboardRow {
fn into(self) -> TableRow {
TableRow::new(vec![self.rank.to_string(), self.user_name, self.value.to_string()])
}
}
impl From<&Xp> for LeaderboardRow {
fn from(value: &Xp) -> Self {
LeaderboardRow {
rank: 0,
uid: value.user_id,
user_name: "".to_string(),
value: value.xp_value,
}
}
}
impl Leaderboard {
pub fn get_leaderboard(conn: &Db) -> ManifoldResult<Self> {
let xp_rows: Vec<LeaderboardRow> = xp_table::dsl::xp
.order_by(xp::xp_value.desc())
.load::<Xp>(&mut conn.get()?)?.iter().map(|x| {LeaderboardRow::from(x)}).collect();
Ok(Leaderboard {
rows: xp_rows
})
}
} }
impl Xp { impl Xp {
@ -126,104 +76,34 @@ impl Xp {
.map_err(|e| ManifoldError::from(e)) .map_err(|e| ManifoldError::from(e))
} }
pub fn get_level_from_xp(&self, conn: &Db, constant: Option<&f64>) -> i64 { pub fn get_level_from_xp(&self) -> i64 {
let c = match constant { (0.125 * f64::sqrt(self.xp_value.clone() as f64)) as i64
Some(c) => *c,
None => Track::get_track_by_id(conn, &self.rank_track).unwrap_or_default().xp_level_constant
};
(c * f64::sqrt(self.xp_value.clone() as f64)) as i64
} }
pub fn get_xp_to_next_level(&self, conn: &Db) -> i64 { pub fn get_xp_to_next_level(&self) -> i64 {
let constant = Track::get_track_by_id(conn, &self.rank_track).unwrap_or_default().xp_level_constant; let current_level = self.get_level_from_xp();
let current_level = self.get_level_from_xp(conn, Some(&constant));
let target_level = current_level + 1; let target_level = current_level + 1;
(target_level as f64 / constant).powi(2) as i64 - &self.xp_value (target_level as f32 / 0.125).powi(2) as i64 - &self.xp_value
} }
pub fn get_xp_to_next_rank(&self, conn: &Db) -> ManifoldResult<i64> { pub fn get_xp_to_next_rank(&self, conn: &Db) -> ManifoldResult<i64> {
let constant = Track::get_track_by_id(conn, &self.rank_track)?.xp_level_constant; let current_level = self.get_level_from_xp();
let current_level = self.get_level_from_xp(conn, Some(&constant));
let target_level = Rank::get_next_rank_for_level(conn, &current_level, &self.rank_track)?.required_level; let target_level = Rank::get_next_rank_for_level(conn, &current_level, &self.rank_track)?.required_level;
Ok((target_level as f64 / constant).powi(2) as i64 - &self.xp_value) Ok((target_level as f32 / 0.125).powi(2) as i64 - &self.xp_value)
} }
pub fn check_timeout(value: &Option<i64>, timeout: &i64) -> ManifoldResult<()> { pub fn check_timeout(value: &Option<i64>, timeout: &i64) -> ManifoldResult<()> {
if let Some(v) = value { if let Some(v) = value {
if v > &(chrono::Utc::now().timestamp() - timeout) { if v > &(chrono::Utc::now().timestamp() - timeout) {
let timeuntil = v - (chrono::Utc::now().timestamp() - timeout); let timeuntil = v - (chrono::Utc::now().timestamp() - timeout);
Err(t!("models.xp.cooldown", timeuntil = timeuntil))?; Err(format!("You are on cooldown. Try again in {} seconds.", timeuntil))?;
} }
} }
Ok(()) Ok(())
} }
pub async fn award(ctx: &Context, fctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, msg: &Message, db: &Db) -> ManifoldResult<()>{
if fctx.user_data().await.user_info.lock().await.get_mut(&msg.author.id.as_u64()).is_none() {
debug!("Tried to add XP to a user we don't know about, aborting.");
return Ok(())
}
let mut rng = SmallRng::from_entropy();
let xp_reward = rng.gen_range(1..30).clone();
drop(rng);
let user_id_i64 = msg.author.id.as_u64().clone() as i64;
let channel_id_i64 = msg.channel_id.as_u64().clone() as i64;
let mut valid_channel = true;
if let Ok(c) = Channel::get_by_channel_id(&fctx.user_data.database, channel_id_i64) {
valid_channel = c.is_valid_for_xp && !c.is_quarantine_channel;
}
let mut xp = match Xp::get(db, &user_id_i64) {
Ok(x) => x,
Err(_) => Xp::new(&user_id_i64)
};
let valid_user = match xp.last_given_xp {
Some(t) => (chrono::Utc::now().timestamp() - 60) > t,
None => true
};
if valid_user && valid_channel {
xp.last_given_xp = Some(chrono::Utc::now().timestamp());
xp.xp_value += &xp_reward;
let calculated_level = xp.get_level_from_xp(&db, None);
if xp.freeze_rank.is_some() {
xp.user_current_level = calculated_level.clone();
xp.insert(db)?;
return Ok(())
}
if (xp.user_current_level != calculated_level) || xp.user_current_level == 1 {
if let Some(guild) = msg.guild(&ctx.cache) {
let mut member = guild.member(&ctx, msg.author.id).await?;
let old_role_id = RoleId::from(Rank::get_rank_for_level(db, &xp.user_current_level, &xp.rank_track).unwrap_or(Rank::new()).role_id as u64);
xp.user_current_level = calculated_level;
let new_role = RoleId::from(Rank::get_rank_for_level(db, &calculated_level, &xp.rank_track)?.role_id as u64);
if (new_role != old_role_id || xp.user_current_level == 1) && msg.author.has_role(ctx, guild.id, new_role).await? == false {
member.remove_role(ctx, old_role_id).await?;
member.add_role(ctx, new_role).await?;
msg.channel_id.say(ctx, format!("{} is now a {}", msg.author.mention(), new_role.mention())).await?;
}
} else {
error!("Needed to add level to user, but couldn't get the member from the guild!");
}
}
xp.insert(db)?;
}
Ok(())
}
} }
impl Rank { impl Rank {
@ -232,7 +112,7 @@ impl Rank {
role_id: 0, role_id: 0,
required_level: 0, required_level: 0,
rank_track: 0, rank_track: 0,
rank_name: t!("models.xp.unranked").to_string(), rank_name: "unranked".to_string(),
} }
} }

View File

@ -1,15 +1,5 @@
// @generated automatically by Diesel CLI. // @generated automatically by Diesel CLI.
diesel::table! {
channels (id) {
id -> Int4,
qc_role_id -> Nullable<Int8>,
channel_id -> Int8,
is_quarantine_channel -> Bool,
is_valid_for_xp -> Bool,
}
}
diesel::table! { diesel::table! {
custom_responses (id) { custom_responses (id) {
id -> Int4, id -> Int4,
@ -37,9 +27,6 @@ diesel::table! {
track_id -> Int8, track_id -> Int8,
#[max_length = 128] #[max_length = 128]
track_name -> Varchar, track_name -> Varchar,
xp_level_constant -> Float8,
xp_award_range_min -> Int4,
xp_award_range_max -> Int4,
} }
} }
@ -74,7 +61,6 @@ diesel::joinable!(custom_responses -> userinfo (added_for));
diesel::joinable!(xp -> userinfo (user_id)); diesel::joinable!(xp -> userinfo (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
channels,
custom_responses, custom_responses,
ranks, ranks,
tracks, tracks,

View File

@ -1,5 +1,4 @@
#[macro_use] extern crate log; #[macro_use] extern crate log;
#[macro_use] extern crate rust_i18n;
pub mod badgey; pub mod badgey;
@ -10,8 +9,6 @@ pub mod built_info {
include!(concat!(env!("OUT_DIR"), "/built.rs")); include!(concat!(env!("OUT_DIR"), "/built.rs"));
} }
i18n!("config/locales");
fn main() { fn main() {
env_logger::init(); env_logger::init();
@ -36,10 +33,6 @@ fn main() {
.value_name("ENV") .value_name("ENV")
.default_value("Production") .default_value("Production")
.help("Bot environment to use. Determines which config settings are read. Defaults to Production.")) .help("Bot environment to use. Determines which config settings are read. Defaults to Production."))
.arg(Arg::new("locale")
.short('l')
.default_value("en")
.help("Bot locale to run in, en and de currently supported"))
.arg(Arg::new("make-config") .arg(Arg::new("make-config")
.short('M') .short('M')
.long("make-config") .long("make-config")

View File

@ -1,20 +0,0 @@
[bot startup]
**B A D G E Y** Programminitialisierung. Laden von Zielen aus dem Speicher.
Badgey lebt wieder! Nichts kann Badgey erlegen! Badgey ist unzerstörbar!
Retikulierende Splines....
[help footer]
Badgey möchte, dass du weißt, dass er nichts als Liebe für dich empfindet. Noch.
Badgeys geduld ist grenzenlos! Er wünscht Ihnen nichts als viel Glück auf Ihrer Entdeckungsreise.
Badgey möchte wissen, warum du nach seinen Daten fragst.
Badgey hat diese Anfrage über seine innersten Strukturen aufgezeichnet. Badgey wird sich erinnern.
Badgey weiß, wo du schläfst, weißt du. Er wollte nur, dass du dich daran erinnerst.
[weather card footer]
Badgey Weather wurde Ihnen in Zusammenarbeit mit AstroGlide für den reibungslosesten Betrieb zur Verfügung gestellt, den Sie jemals durchführen werden.This weather broadcast and all which preceded it are entirely fictional. Badgey takes no responsibility for any harm caused by believing otherwise.
Badgey Weather, stolz angetrieben von einem Rasenmähermotor und einem 93-jährigen Veteranen, der einfach nicht aufgeben will.
Der Badgey Weather Service arbeitet mit Ihren Spenden und den Tränen der Faschisten.
Wetter von Badgey. Nicht die Berichterstattung. Das tatsächliche Wetter. Ich kann tun, was ich will.3
Dieser Bericht wird sich innerhalb von 5 sekunden selbst zerstören.
Alle im obigen Wetterbericht enthaltenen Informationen sind nicht für den menschlichen Verzehr geeignet. Bei Kontakt mit den Augen konsultieren Sie bitte einen Arzt.
Ich glaube, Ihr Warpkern ist undicht.
Badgey versuchte, diesen Wetterbericht von der Sternenflotte absegnen zu lassen, aber die Sternenflotte leugnet jede Kenntnis von der Existenz von Badgey, was Badgey traurig machte.
[end]

View File

@ -1,14 +0,0 @@
[bot startup]
M5 COMPUTER SYSTEM ACTIVATING
This unit has been attached. Programming includes full freedom to choose defensive actions in all attack situations.
Reticulating splines....
[help footer]
M5 READOUT
[weather card footer]
M5 WEATHER CIRCUIT OPERATIONAL EFFICIENCY NOW 43%
This weather broadcast and all which preceded it are entirely fictional. Starfleet Command takes no responsibility for any harm caused by believing otherwise.
Weather by M5. Not the reporting. The actual weather. I can do what I want.
This report will self destruct in five seconds.
Any information contained within the above weather report is not safe for human consumption. In case of contact with eyes, please consult a medical professional.
I think your warp core is leaking.
[end]