Compare commits
33 commits
adbbc6352b
...
master
Author | SHA1 | Date | |
---|---|---|---|
37a6ba07e8 | |||
ca2b2c45a4 | |||
084502ef08 | |||
8fcc250041 | |||
424078deff | |||
6c01c68429 | |||
747d17d8d3 | |||
77e51c8229 | |||
52116229e6 | |||
7e9e479e06 | |||
7cbdafe88c | |||
885c397a16 | |||
99572fd97a | |||
b0c52d79d8 | |||
00c6b36344 | |||
04aa205fc3 | |||
c71cb1b4ab | |||
1aaf0d366b | |||
30057b2779 | |||
85a4fa85ae | |||
fa88690f92 | |||
20f0756f99 | |||
ac37d8ed56 | |||
af563d8a2c | |||
cdb06b320c | |||
df44328c35 | |||
2b8d0a6b15 | |||
aad869cd5f | |||
8f74565abd | |||
7c89395f70 | |||
beeb76b2ed | |||
ab633d50d3 | |||
70867e0d7e |
28 changed files with 819 additions and 129 deletions
.gitignoreREADME.md
bin
cobuild
cogs
common
config.example.jsondata.example
hrochobot
main.pyrequirements.txtsetup.pyutils
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1 +1,2 @@
|
||||||
config.json
|
/config.json
|
||||||
|
/data/
|
78
README.md
Normal file
78
README.md
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
# Hrochobot
|
||||||
|
Discord bot for KSP discord server.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
First install all required libraries:
|
||||||
|
```sh
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create ``data`` folder from ``data.example``:
|
||||||
|
```sh
|
||||||
|
cp data.example data -r
|
||||||
|
```
|
||||||
|
Same for ``config.json``:
|
||||||
|
```sh
|
||||||
|
cp config.example.json config.json -r
|
||||||
|
```
|
||||||
|
|
||||||
|
Lastly paste your discord bot token into ``config.json``.
|
||||||
|
If you do not have any discord bot, learn how to create one here:
|
||||||
|
https://discordjs.guide/preparations/setting-up-a-bot-application.html#your-bot-s-token
|
||||||
|
|
||||||
|
|
||||||
|
## Running
|
||||||
|
To run the bot simply execute ``main.py``:
|
||||||
|
```sh
|
||||||
|
./main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
### Basic
|
||||||
|
#### `/sayhello`
|
||||||
|
Prints ``Hello world!``
|
||||||
|
|
||||||
|
#### `/ping`
|
||||||
|
Shows the bot's latency.
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
#### `/set_forward_channel <#channel>`
|
||||||
|
Sets channel to forward messages to.
|
||||||
|
|
||||||
|
#### `>forward`
|
||||||
|
Forwards message to set channel
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
#### `/secretroles`
|
||||||
|
Manages roles locked behind a password.
|
||||||
|
|
||||||
|
Lock ``@role`` behind a password ``supersecret``:
|
||||||
|
```
|
||||||
|
/secretroles add @role supersecret
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete password ``supersecret``, so role is no longer obtainable with it:
|
||||||
|
```
|
||||||
|
/secretroles delete supersecret
|
||||||
|
```
|
||||||
|
|
||||||
|
List all current passwords and their roles:
|
||||||
|
```
|
||||||
|
/secretroles list
|
||||||
|
```
|
||||||
|
|
||||||
|
### KSP
|
||||||
|
Ksp related commands
|
||||||
|
|
||||||
|
#### `/task`
|
||||||
|
Generates urls for given task.
|
||||||
|
|
||||||
|
#### `/deadlines`
|
||||||
|
Shows deadlines of currently running series.
|
||||||
|
|
||||||
|
### News
|
||||||
|
#### `/news set_channel <#channel>`
|
||||||
|
Set channel for posting news.
|
||||||
|
|
||||||
|
#### `/news post_news <id>`
|
||||||
|
Post news with given `id` to set channel.
|
30
bin/hrochobot
Executable file
30
bin/hrochobot
Executable file
|
@ -0,0 +1,30 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from discord.ext import commands
|
||||||
|
import hrochobot.utils.data as data
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
data.DATA_FOLDER = os.environ.get("HROCHOBOT_DATA", 'data')
|
||||||
|
LOG_FOLDER = os.environ.get("HROCHOBOT_LOG", '.')
|
||||||
|
CONFIG_FOLDER = os.environ.get("HROCHOBOT_ETC", '.')
|
||||||
|
|
||||||
|
logger = logging.getLogger('hrochobot')
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
handler = logging.FileHandler(filename=os.path.join(LOG_FOLDER, 'hrochobot.log'), encoding='utf-8', mode='w')
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
CONFIG = data.load_json(os.path.join(CONFIG_FOLDER, "config"))
|
||||||
|
|
||||||
|
bot = commands.Bot()
|
||||||
|
|
||||||
|
cogs_list = CONFIG["enabled_cogs"]
|
||||||
|
|
||||||
|
for cog in cogs_list:
|
||||||
|
bot.load_extension(f'hrochobot.cogs.{cog}')
|
||||||
|
|
||||||
|
@bot.listen('on_interaction')
|
||||||
|
async def statistics(interaction):
|
||||||
|
logger.info(f"{interaction.user} ({interaction.user.id}) used command {interaction.data['name']}.")
|
||||||
|
|
||||||
|
bot.run(CONFIG["token"])
|
12
cobuild/000-setup.sh
Normal file
12
cobuild/000-setup.sh
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
progress "Installing Hrochobot"
|
||||||
|
|
||||||
|
echo "Creating virtual environment"
|
||||||
|
|
||||||
|
install-pkgs python3-venv
|
||||||
|
|
||||||
|
python3 -m venv /srv/hrochobot
|
||||||
|
. /srv/hrochobot/bin/activate
|
||||||
|
|
||||||
|
echo "Installing package"
|
||||||
|
cd /build/src
|
||||||
|
pip install .
|
16
cobuild/010-service.d/hrochobot.service
Normal file
16
cobuild/010-service.d/hrochobot.service
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
[Unit]
|
||||||
|
Description="Hrochobot"
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
ExecStartPre=mkdir -p /data/hrochobot
|
||||||
|
ExecStart=/srv/hrochobot/bin/hrochobot
|
||||||
|
Environment=HROCHOBOT_DATA=/data/hrochobot
|
||||||
|
Environment=HROCHOBOT_ETC=/data/hrochobot
|
||||||
|
Environment=HROCHOBOT_LOG=/data/log
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
4
cobuild/010-service.d/run.sh
Normal file
4
cobuild/010-service.d/run.sh
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
progress "Configuring hrochobot.service"
|
||||||
|
|
||||||
|
install-config hrochobot.service /etc/systemd/system/
|
||||||
|
systemctl enable hrochobot
|
4
cobuild/Dockerfile.top
Normal file
4
cobuild/Dockerfile.top
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
FROM docker://registry.ks.matfyz.cz/gimli/base:bookworm
|
||||||
|
COPY bin /build/src/bin
|
||||||
|
COPY hrochobot /build/src/hrochobot
|
||||||
|
COPY setup.py /build/src
|
6
cobuild/try
Executable file
6
cobuild/try
Executable file
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
common/build cobuild --tag hrochobot-test
|
||||||
|
podman rm -if hrochobot-test
|
||||||
|
podman create --name hrochobot-test --hostname hrochobot-test hrochobot-test
|
||||||
|
podman start hrochobot-test
|
19
cogs/ksp.py
19
cogs/ksp.py
|
@ -1,19 +0,0 @@
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
from utils.ksp_utils import *
|
|
||||||
|
|
||||||
class Ksp(commands.Cog):
|
|
||||||
def __init__(self, bot):
|
|
||||||
self.bot = bot
|
|
||||||
|
|
||||||
@discord.slash_command()
|
|
||||||
async def task(self, ctx, task_code):
|
|
||||||
await ctx.respond(
|
|
||||||
f'**{task_code}**\n'
|
|
||||||
f'Task: {task_link(task_code, solution=False)}\n'
|
|
||||||
f'Solution: {task_link(task_code, solution=True)}',
|
|
||||||
ephemeral=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def setup(bot):
|
|
||||||
bot.add_cog(Ksp(bot))
|
|
|
@ -1,65 +0,0 @@
|
||||||
import discord
|
|
||||||
import utils.data as data
|
|
||||||
from discord.ext import commands
|
|
||||||
from discord.utils import get
|
|
||||||
|
|
||||||
ROLES_JSON = "roles"
|
|
||||||
|
|
||||||
class Roles(commands.Cog):
|
|
||||||
def __init__(self, bot):
|
|
||||||
self.bot = bot
|
|
||||||
|
|
||||||
secret_roles = discord.SlashCommandGroup(
|
|
||||||
"secretroles",
|
|
||||||
checks=[commands.has_permissions(manage_roles=True)]
|
|
||||||
)
|
|
||||||
|
|
||||||
@secret_roles.command()
|
|
||||||
async def add(self, ctx, role: discord.role.Role, password: str):
|
|
||||||
roles = data.load_data(ROLES_JSON)
|
|
||||||
if password in roles["secret_roles"]:
|
|
||||||
return await ctx.respond(f"Password ``{password}`` is already used.", ephemeral=True)
|
|
||||||
|
|
||||||
roles["secret_roles"][password] = role.id
|
|
||||||
data.dump_data(ROLES_JSON, roles)
|
|
||||||
return await ctx.respond(f"Secret role {role.mention} added with password {password}.", ephemeral=True)
|
|
||||||
|
|
||||||
@secret_roles.command()
|
|
||||||
async def list(self, ctx):
|
|
||||||
roles = data.load_data(ROLES_JSON)
|
|
||||||
if len(roles["secret_roles"]) == 0:
|
|
||||||
return await ctx.respond(f"No current secret roles.", ephemeral=True)
|
|
||||||
msg = ""
|
|
||||||
for passwd, role in roles["secret_roles"].items():
|
|
||||||
role = get(ctx.author.guild.roles, id=role)
|
|
||||||
msg += f"``{passwd}``: {role.mention}\n"
|
|
||||||
return await ctx.respond(msg, ephemeral=True)
|
|
||||||
|
|
||||||
@secret_roles.command()
|
|
||||||
async def delete(self, ctx, password: str):
|
|
||||||
roles = data.load_data(ROLES_JSON)
|
|
||||||
if password not in roles["secret_roles"]:
|
|
||||||
return await ctx.respond(f"Role with passowrd {password} does not exist.", ephemeral=True)
|
|
||||||
|
|
||||||
role = get(ctx.author.guild.roles, id=roles["secret_roles"][password])
|
|
||||||
del roles["secret_roles"][password]
|
|
||||||
data.dump_data(ROLES_JSON, roles)
|
|
||||||
return await ctx.respond(
|
|
||||||
f"Secret role {role.mention} no longer obtainable with password {password}.",
|
|
||||||
ephemeral=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@discord.slash_command()
|
|
||||||
async def secretrole(self, ctx, password: str):
|
|
||||||
roles = data.load_data(ROLES_JSON)
|
|
||||||
if password in roles["secret_roles"]:
|
|
||||||
author = ctx.author
|
|
||||||
role = get(author.guild.roles, id=roles["secret_roles"][password])
|
|
||||||
await author.add_roles(role, reason="Roles assigned for password knowledge.")
|
|
||||||
return await ctx.respond(f"You now have role {role.mention}.", ephemeral=True)
|
|
||||||
else:
|
|
||||||
return await ctx.respond("Incorrect password.", ephemeral=True)
|
|
||||||
|
|
||||||
def setup(bot):
|
|
||||||
bot.add_cog(Roles(bot))
|
|
73
common/build
Executable file
73
common/build
Executable file
|
@ -0,0 +1,73 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
gen-docker-file ()
|
||||||
|
{
|
||||||
|
if [ -f $src/Dockerfile.top ] ; then
|
||||||
|
cat $src/Dockerfile.top
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "COPY common /build/common"
|
||||||
|
|
||||||
|
for stage in $(cd $src && echo [0-9]*.[a-z]*) ; do
|
||||||
|
case $stage in
|
||||||
|
*.docker)
|
||||||
|
cat $src/$stage
|
||||||
|
;;
|
||||||
|
*.d|*.sh)
|
||||||
|
echo "COPY $src/$stage /build/$stage"
|
||||||
|
echo "RUN /build/common/run $stage"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo >&2 "ERROR: Unrecognized build stage name $stage"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -f $src/Dockerfile.bottom ] ; then
|
||||||
|
cat $src/Dockerfile.bottom
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "RUN rm -rf /build /data"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ $# = 0 ] ; then
|
||||||
|
echo >&2 "Usage: $0 <src-directory> [<podman build options>]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
src=$1
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [ ! -v http_proxy ] ; then
|
||||||
|
PROXY=
|
||||||
|
eval "$(apt-config shell PROXY Acquire::http::proxy)"
|
||||||
|
if [ -n "$PROXY" ] ; then
|
||||||
|
export http_proxy=$PROXY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -v http_proxy -a ! -v https_proxy ] ; then
|
||||||
|
export https_proxy=$http_proxy
|
||||||
|
fi
|
||||||
|
|
||||||
|
CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache}/container-build
|
||||||
|
if [ -d $CACHE_DIR ] ; then
|
||||||
|
echo "Using cache $CACHE_DIR"
|
||||||
|
else
|
||||||
|
echo "Creating cache $CACHE_DIR"
|
||||||
|
mkdir -p $CACHE_DIR
|
||||||
|
fi
|
||||||
|
mkdir -p $CACHE_DIR/download
|
||||||
|
|
||||||
|
gen-docker-file | podman build \
|
||||||
|
--file - \
|
||||||
|
--layers \
|
||||||
|
--http-proxy \
|
||||||
|
--volume=$CACHE_DIR:/root/.cache \
|
||||||
|
"$@" \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo -n "Cache usage: "
|
||||||
|
du -sh $CACHE_DIR | cut -d ' ' -f1
|
152
common/lib.sh
Normal file
152
common/lib.sh
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
C_RED="$(echo -e '\e[31m')"
|
||||||
|
C_GREEN="$(echo -e '\e[32m')"
|
||||||
|
C_YELLOW="$(echo -e '\e[33m')"
|
||||||
|
C_NORMAL="$(echo -e '\e[0m')"
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
B_APT_UPDATED=
|
||||||
|
|
||||||
|
progress ()
|
||||||
|
{
|
||||||
|
echo "${C_GREEN}$1${C_NORMAL}" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
note ()
|
||||||
|
{
|
||||||
|
echo "${C_YELLOW}$1${C_NORMAL}" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
warn ()
|
||||||
|
{
|
||||||
|
echo "${C_RED}WARNING: $1${C_NORMAL}" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
die ()
|
||||||
|
{
|
||||||
|
echo "${C_RED}ERROR: $1${C_NORMAL}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
update-pkgs ()
|
||||||
|
{
|
||||||
|
if [ -z "$B_APT_UPDATED" ] ; then
|
||||||
|
apt update
|
||||||
|
B_APT_UPDATED=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install-pkgs ()
|
||||||
|
{
|
||||||
|
update-pkgs
|
||||||
|
apt install -y --no-install-recommends --no-install-suggests "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
install-config ()
|
||||||
|
{
|
||||||
|
[ $# = 2 ] || die "Usage: install-config <source> (<target-file> | <target-dir>/)"
|
||||||
|
local S=$1
|
||||||
|
local T=$2
|
||||||
|
if [ ${T%/} != $T ] ; then
|
||||||
|
T=$T$(basename $S)
|
||||||
|
fi
|
||||||
|
local B=$BUILD_CONFIG$T
|
||||||
|
mkdir -p $(dirname $B) $(dirname $T)
|
||||||
|
if [ -f $T ] ; then
|
||||||
|
if [ ! -f $B.orig ] ; then
|
||||||
|
echo "Backing up $T to $B.orig"
|
||||||
|
cp $T $B.orig
|
||||||
|
else
|
||||||
|
echo "Backup of $T already present in $B.orig"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -f $S ] ; then
|
||||||
|
# Just a new file: overwrite
|
||||||
|
echo "Overwriting $T"
|
||||||
|
cp $S $T
|
||||||
|
cp $S $B.new
|
||||||
|
elif [ -f $S.orig -a -f $S.new ] ; then
|
||||||
|
# Try 3-way merge
|
||||||
|
if cmp --quiet $S.new $T ; then
|
||||||
|
warn "Skipping merge of $T: changes already present"
|
||||||
|
else
|
||||||
|
echo "Merging $T"
|
||||||
|
if diff3 -m $S.new $S.orig $T >$B.new ; then
|
||||||
|
cp $B.new $T
|
||||||
|
else
|
||||||
|
die "Merge failed, please review $B.new"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
die "Do not know how to install config $T"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
sed-config ()
|
||||||
|
{
|
||||||
|
[ $# = 2 ] || die "Usage: sed-config <target> <sed-script>"
|
||||||
|
local T=$1
|
||||||
|
local SCRIPT=$2
|
||||||
|
local B=$BUILD_CONFIG$T
|
||||||
|
mkdir -p $(dirname $B)
|
||||||
|
[ -f $T ] || die "Want to sed $T, but it is missing"
|
||||||
|
if [ ! -f $B.orig ] ; then
|
||||||
|
echo "Backing up $T to $B.orig"
|
||||||
|
cp $T $B.orig
|
||||||
|
else
|
||||||
|
echo "Backup of $T already present in $B.orig"
|
||||||
|
fi
|
||||||
|
sed -ri "$SCRIPT" $T
|
||||||
|
cp $T $B.new
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe-config ()
|
||||||
|
{
|
||||||
|
[ $# = 1 ] || die "Usage: <command> | pipe-config <target>"
|
||||||
|
local TMP=$(mktemp)
|
||||||
|
cat >$TMP
|
||||||
|
install-config $TMP $1
|
||||||
|
rm $TMP
|
||||||
|
}
|
||||||
|
|
||||||
|
rm-config ()
|
||||||
|
{
|
||||||
|
[ $# = 1 ] || die "Usage: rm-config <target>"
|
||||||
|
local T=$1
|
||||||
|
local B=$BUILD_CONFIG$T
|
||||||
|
if [ -f $T ] ; then
|
||||||
|
if [ ! -f $B.orig ] ; then
|
||||||
|
echo "Backing up $T to $B.orig"
|
||||||
|
cp $T $B.orig
|
||||||
|
fi
|
||||||
|
echo "Removing $T"
|
||||||
|
rm -f $T
|
||||||
|
else
|
||||||
|
echo "Wanted to remove $T, but it does not exist"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
download ()
|
||||||
|
{
|
||||||
|
[ $# = 3 ] || die "Usage: download <URL> <hash-type> <hash>"
|
||||||
|
local URL=$1
|
||||||
|
local HTYPE=$2
|
||||||
|
local HASH=$3
|
||||||
|
local DEST=$(basename $URL)
|
||||||
|
local CACHE=/root/.cache/download
|
||||||
|
if [ -d $CACHE ] ; then
|
||||||
|
if [ -f $CACHE/$DEST ] ; then
|
||||||
|
echo "Using cached $DEST"
|
||||||
|
else
|
||||||
|
echo "Downloading $URL with caching"
|
||||||
|
curl $URL >$CACHE/$DEST.tmp
|
||||||
|
mv $CACHE/$DEST.tmp $CACHE/$DEST
|
||||||
|
fi
|
||||||
|
ln -s $CACHE/$DEST .
|
||||||
|
else
|
||||||
|
warn "Cache $CACHE not found: downloading directly"
|
||||||
|
echo "Downloading $URL"
|
||||||
|
curl $URL >$DEST
|
||||||
|
fi
|
||||||
|
local H=$(${HTYPE}sum $DEST | cut -d' ' -f1)
|
||||||
|
[ $H == $HASH ] || die "Mismatched hash: got $H, want $HASH"
|
||||||
|
}
|
50
common/run
Executable file
50
common/run
Executable file
|
@ -0,0 +1,50 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# != 1 ] ; then
|
||||||
|
echo >&2 "Usage: $0 <stage-directory>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAGE=$1
|
||||||
|
|
||||||
|
STAGE=/build/$STAGE
|
||||||
|
BUILD_COMMON=/build/common
|
||||||
|
BUILD_CONFIG=/build/config-tmp
|
||||||
|
. $BUILD_COMMON/lib.sh
|
||||||
|
|
||||||
|
note "Running $STAGE"
|
||||||
|
|
||||||
|
if [ ! -v http_proxy -o ! -v https_proxy ] ; then
|
||||||
|
warn "No HTTP(S) proxy is set"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$STAGE" in
|
||||||
|
*.sh)
|
||||||
|
. $STAGE
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*.d)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unrecognized stage name $STAGE"
|
||||||
|
esac
|
||||||
|
|
||||||
|
cd $STAGE
|
||||||
|
|
||||||
|
if [ -f run.sh ] ; then
|
||||||
|
. run.sh
|
||||||
|
else
|
||||||
|
for a in [0-9]* ; do
|
||||||
|
case "$a" in
|
||||||
|
*.sh)
|
||||||
|
( . $a )
|
||||||
|
;;
|
||||||
|
*.d)
|
||||||
|
( cd $a && . run.sh )
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "Unrecognized build step file $a"
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
10
config.example.json
Normal file
10
config.example.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"token": "Paste your token here.",
|
||||||
|
"enabled_cogs": [
|
||||||
|
"basic",
|
||||||
|
"roles",
|
||||||
|
"messages",
|
||||||
|
"ksp",
|
||||||
|
"news"
|
||||||
|
]
|
||||||
|
}
|
3
data.example/roles.json
Normal file
3
data.example/roles.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"secret_roles": {}
|
||||||
|
}
|
|
@ -5,11 +5,11 @@ class Basic(commands.Cog):
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@discord.slash_command()
|
@discord.slash_command(description="Greets the world.")
|
||||||
async def sayhello(self, ctx):
|
async def sayhello(self, ctx):
|
||||||
await ctx.respond('Hello world!')
|
await ctx.respond('Hello world!')
|
||||||
|
|
||||||
@discord.slash_command()
|
@discord.slash_command(description="Sends the bot's latency.")
|
||||||
async def ping(self, ctx):
|
async def ping(self, ctx):
|
||||||
await ctx.respond('My ping is {:.0f}ms'.format(self.bot.latency*1000), ephemeral=True)
|
await ctx.respond('My ping is {:.0f}ms'.format(self.bot.latency*1000), ephemeral=True)
|
||||||
|
|
32
hrochobot/cogs/ksp.py
Normal file
32
hrochobot/cogs/ksp.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
from hrochobot.utils.ksp_utils import *
|
||||||
|
|
||||||
|
class Ksp(commands.Cog):
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
@discord.slash_command(description="Generates urls for given task.")
|
||||||
|
async def task(self, ctx, task_code: str):
|
||||||
|
await ctx.respond(
|
||||||
|
f'**{task_code}**\n'
|
||||||
|
f'Task: {task_link(task_code, solution=False)}\n'
|
||||||
|
f'Solution: {task_link(task_code, solution=True)}',
|
||||||
|
ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@discord.slash_command(description="Shows deadlines of currently running series.")
|
||||||
|
async def deadlines(self, ctx):
|
||||||
|
a_deadlines = active_deadlines()
|
||||||
|
if len(a_deadlines) == 0:
|
||||||
|
return await ctx.respond("No running series.", ephemeral=True)
|
||||||
|
|
||||||
|
msg = ""
|
||||||
|
for series_id, deadline in a_deadlines:
|
||||||
|
msg += f"Sérii **{series_id}** odevzdávejte do " + \
|
||||||
|
deadline.strftime('%-d. %-m. %Y %-H:%M') + '\n'
|
||||||
|
|
||||||
|
return await ctx.respond(msg, ephemeral=True)
|
||||||
|
|
||||||
|
def setup(bot):
|
||||||
|
bot.add_cog(Ksp(bot))
|
37
hrochobot/cogs/messages.py
Normal file
37
hrochobot/cogs/messages.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
from discord.utils import get
|
||||||
|
import hrochobot.utils.data as data
|
||||||
|
|
||||||
|
MESSAGES_JSON = "messages"
|
||||||
|
|
||||||
|
def as_reply(message: str):
|
||||||
|
return "\n".join(map(lambda x: f"> {x}", message.split('\n')))
|
||||||
|
|
||||||
|
class Messages(commands.Cog):
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
@discord.message_command()
|
||||||
|
async def forward(self, ctx, message: discord.Message):
|
||||||
|
messages_json = data.load_guild_data(ctx.guild.id, MESSAGES_JSON)
|
||||||
|
if "forward_channel" not in messages_json:
|
||||||
|
return await ctx.respond(f"Forwarding channel not set.", ephemeral=True)
|
||||||
|
|
||||||
|
forward_channel = get(ctx.guild.channels, id=messages_json["forward_channel"])
|
||||||
|
header_text = f"{ctx.author.mention} forwarded:\n{message.author.mention} wrote in {message.channel.mention}:\n"
|
||||||
|
await forward_channel.send(f"{header_text}{as_reply(message.clean_content)}")
|
||||||
|
return await ctx.respond("Message successfully forwarded.", ephemeral=True)
|
||||||
|
|
||||||
|
@discord.slash_command(description="Sets channel for forwarding.")
|
||||||
|
@discord.option("channel", discord.TextChannel, description="Channel for forwarding.")
|
||||||
|
@discord.default_permissions(administrator=True)
|
||||||
|
async def set_forward_channel(self, ctx, channel: discord.TextChannel):
|
||||||
|
messages_json = data.load_guild_data(ctx.guild.id, MESSAGES_JSON)
|
||||||
|
messages_json["forward_channel"] = channel.id
|
||||||
|
data.dump_guild_data(ctx.guild.id, MESSAGES_JSON, messages_json)
|
||||||
|
return await ctx.respond(f"Forwarding channel set to {channel.mention}.\n" + \
|
||||||
|
"Remember to set permissions for forwarding.", ephemeral=True)
|
||||||
|
|
||||||
|
def setup(bot):
|
||||||
|
bot.add_cog(Messages(bot))
|
118
hrochobot/cogs/news.py
Normal file
118
hrochobot/cogs/news.py
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
from datetime import datetime
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
from discord.utils import get
|
||||||
|
from markdownify import markdownify
|
||||||
|
import re
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from hrochobot.utils.ksp_utils import ksp_feed, strip_id, KSP_URL
|
||||||
|
import hrochobot.utils.data as data
|
||||||
|
|
||||||
|
NEWS_JSON = "news"
|
||||||
|
|
||||||
|
async def get_news_ids():
|
||||||
|
feed = await ksp_feed()
|
||||||
|
return list(map(lambda e: e.id, feed.entries))
|
||||||
|
|
||||||
|
async def autocomplete_news_ids(ctx):
|
||||||
|
value = ctx.value.lower()
|
||||||
|
options = []
|
||||||
|
for id_ in map(strip_id, await get_news_ids()):
|
||||||
|
lid = id_.lower()
|
||||||
|
if lid.startswith(value) or lid.split("_", 1)[1].startswith(value):
|
||||||
|
options.append(id_)
|
||||||
|
return options
|
||||||
|
|
||||||
|
def guess_color(title):
|
||||||
|
"""
|
||||||
|
Automagically guess color of given post.
|
||||||
|
Not always reliable as all things automagic.
|
||||||
|
"""
|
||||||
|
def contains(*regexes):
|
||||||
|
return any(re.search(regex, title) for regex in regexes)
|
||||||
|
|
||||||
|
if contains(r"(\d+)-Z(\d+)", "začátečnic", "KSP-Z"):
|
||||||
|
return discord.Color.green()
|
||||||
|
elif contains(r"(\d+)-(\d+)", "seriál", "série", "KSP-H"):
|
||||||
|
return discord.Color.blue()
|
||||||
|
else:
|
||||||
|
return discord.Color.dark_purple()
|
||||||
|
|
||||||
|
def format_entry(entry, author=None):
|
||||||
|
content = "\n\n".join(map(lambda x: x.replace('\n', ' '), entry.summary.split("\n\n")))
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=entry.title,
|
||||||
|
url=entry.link,
|
||||||
|
description=markdownify(content, strip=["img"]),
|
||||||
|
color=guess_color(entry.title),
|
||||||
|
)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(content, 'html.parser')
|
||||||
|
img = soup.find('img')
|
||||||
|
if img:
|
||||||
|
embed.set_image(url=img['src'])
|
||||||
|
|
||||||
|
if author:
|
||||||
|
embed.set_author(name=author)
|
||||||
|
|
||||||
|
embed.set_thumbnail(url=f"{KSP_URL}/img/hippo_head.png")
|
||||||
|
|
||||||
|
date = datetime.fromisoformat(entry.published)
|
||||||
|
embed.set_footer(text=date.strftime("%-d. %-m. %Y"))
|
||||||
|
|
||||||
|
return embed
|
||||||
|
|
||||||
|
async def post_news(bot, guild, entry_id):
|
||||||
|
news_json = data.load_guild_data(guild.id, NEWS_JSON)
|
||||||
|
if "news_channel" not in news_json:
|
||||||
|
return "News channel not set."
|
||||||
|
|
||||||
|
channel = get(guild.channels, id=news_json["news_channel"])
|
||||||
|
feed = await ksp_feed()
|
||||||
|
entries_with_id = list(filter(lambda e: strip_id(e.id) == entry_id, feed.entries))
|
||||||
|
if len(entries_with_id) == 0:
|
||||||
|
return f"Entry with id ``{entry_id}`` not found."
|
||||||
|
|
||||||
|
await channel.send(embed=format_entry(entries_with_id[0], author=feed.feed.author))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class News(commands.Cog):
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
news = discord.SlashCommandGroup(
|
||||||
|
"news",
|
||||||
|
"Commands for management of ksp news.",
|
||||||
|
guild_only=True,
|
||||||
|
checks=[commands.has_permissions(manage_guild=True)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@news.command(description="Sets channel for posting news.")
|
||||||
|
@discord.option("channel_id", str, description="Id of the channel for sending news.")
|
||||||
|
async def set_channel(self, ctx, channel_id: str):
|
||||||
|
try:
|
||||||
|
channel_id = int(channel_id)
|
||||||
|
except ValueError:
|
||||||
|
return await ctx.respond(f"Channel id must be int.", ephemeral=True)
|
||||||
|
|
||||||
|
if not (channel := get(ctx.guild.channels, id=channel_id)):
|
||||||
|
return await ctx.respond(f"No channel with id ``{channel_id}``.", ephemeral=True)
|
||||||
|
|
||||||
|
news_json = data.load_guild_data(ctx.guild.id, NEWS_JSON)
|
||||||
|
news_json["news_channel"] = channel_id
|
||||||
|
data.dump_guild_data(ctx.guild.id, NEWS_JSON, news_json)
|
||||||
|
return await ctx.respond(f"News channel set to {channel.mention}.", ephemeral=True)
|
||||||
|
|
||||||
|
@news.command(description="Posts news of given id to set channel.")
|
||||||
|
@discord.option("id", str, description="Id of entry to send.", autocomplete=autocomplete_news_ids)
|
||||||
|
async def post_news(self, ctx, id: int):
|
||||||
|
await ctx.defer(ephemeral=True)
|
||||||
|
err = await post_news(self.bot, ctx.guild, id)
|
||||||
|
if err:
|
||||||
|
return await ctx.respond(err, ephemeral=True)
|
||||||
|
return await ctx.respond(f"News posted.")
|
||||||
|
|
||||||
|
|
||||||
|
def setup(bot):
|
||||||
|
bot.add_cog(News(bot))
|
75
hrochobot/cogs/roles.py
Normal file
75
hrochobot/cogs/roles.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import discord
|
||||||
|
import hrochobot.utils.data as data
|
||||||
|
from discord.ext import commands
|
||||||
|
from discord.utils import get
|
||||||
|
|
||||||
|
ROLES_JSON = "roles"
|
||||||
|
|
||||||
|
def role_mention(role):
|
||||||
|
return role.mention if role else "@deleted-role"
|
||||||
|
|
||||||
|
class Roles(commands.Cog):
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
secret_roles = discord.SlashCommandGroup(
|
||||||
|
"secretroles",
|
||||||
|
"Commands for management of secret roles.",
|
||||||
|
guild_only=True,
|
||||||
|
checks=[commands.has_permissions(manage_roles=True)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@secret_roles.command(description="Adds a new secret role.")
|
||||||
|
@discord.option("role", discord.role.Role, description="Role locked behind a password.")
|
||||||
|
@discord.option("password", str, description="Password for given role.")
|
||||||
|
async def add(self, ctx, role, password):
|
||||||
|
roles = data.load_guild_data(ctx.author.guild.id, ROLES_JSON)
|
||||||
|
if password in roles["secret_roles"]:
|
||||||
|
return await ctx.respond(f"Password ``{password}`` is already used.", ephemeral=True)
|
||||||
|
|
||||||
|
roles["secret_roles"][password] = role.id
|
||||||
|
data.dump_guild_data(ctx.author.guild.id, ROLES_JSON, roles)
|
||||||
|
return await ctx.respond(f"Secret role {role.mention} added with password {password}.", ephemeral=True)
|
||||||
|
|
||||||
|
@secret_roles.command(description="Lists all passwords and their secret roles.")
|
||||||
|
async def list(self, ctx):
|
||||||
|
roles = data.load_guild_data(ctx.author.guild.id, ROLES_JSON)
|
||||||
|
if len(roles["secret_roles"]) == 0:
|
||||||
|
return await ctx.respond(f"No current secret roles.", ephemeral=True)
|
||||||
|
msg = ""
|
||||||
|
for passwd, role in roles["secret_roles"].items():
|
||||||
|
role = get(ctx.author.guild.roles, id=role)
|
||||||
|
msg += f"``{passwd}``: {role_mention(role)}\n"
|
||||||
|
return await ctx.respond(msg, ephemeral=True)
|
||||||
|
|
||||||
|
@secret_roles.command(description="Deletes given password and its secret role.")
|
||||||
|
@discord.option("password", str, description="Password to be deleted.")
|
||||||
|
async def delete(self, ctx, password):
|
||||||
|
roles = data.load_guild_data(ctx.author.guild.id, ROLES_JSON)
|
||||||
|
if password not in roles["secret_roles"]:
|
||||||
|
return await ctx.respond(f"Role with passowrd {password} does not exist.", ephemeral=True)
|
||||||
|
|
||||||
|
role = get(ctx.author.guild.roles, id=roles["secret_roles"][password])
|
||||||
|
del roles["secret_roles"][password]
|
||||||
|
data.dump_guild_data(ctx.author.guild.id, ROLES_JSON, roles)
|
||||||
|
return await ctx.respond(
|
||||||
|
f"Secret role {role_mention(role)} no longer obtainable with password {password}.",
|
||||||
|
ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@discord.slash_command(description="Gives a secret role locked by a password.", guild_only=True)
|
||||||
|
@discord.option("password", str, description="Password for secret role.")
|
||||||
|
async def secretrole(self, ctx, password):
|
||||||
|
roles = data.load_guild_data(ctx.author.guild.id, ROLES_JSON)
|
||||||
|
if password in roles["secret_roles"]:
|
||||||
|
author = ctx.author
|
||||||
|
role = get(author.guild.roles, id=roles["secret_roles"][password])
|
||||||
|
if role is None:
|
||||||
|
return await ctx.respond(f"Role for this password was deleted.", ephemeral=True)
|
||||||
|
await author.add_roles(role, reason="Role assigned for password knowledge.")
|
||||||
|
return await ctx.respond(f"You now have role {role.mention}.", ephemeral=True)
|
||||||
|
else:
|
||||||
|
return await ctx.respond("Incorrect password.", ephemeral=True)
|
||||||
|
|
||||||
|
def setup(bot):
|
||||||
|
bot.add_cog(Roles(bot))
|
38
hrochobot/utils/data.py
Normal file
38
hrochobot/utils/data.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
from typing import Any
|
||||||
|
import json
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
from hrochobot.utils.json_templates import TEMPLATES
|
||||||
|
|
||||||
|
EXAMPLE_DATA = "data.example"
|
||||||
|
DATA_FOLDER = "TODO"
|
||||||
|
|
||||||
|
def load_json(filename: str):
|
||||||
|
filename += ".json"
|
||||||
|
if not os.path.exists(filename):
|
||||||
|
return json.loads(TEMPLATES[os.path.basename(filename)])
|
||||||
|
|
||||||
|
with open(filename) as f:
|
||||||
|
content = json.load(f)
|
||||||
|
return content
|
||||||
|
|
||||||
|
def dump_json(filename: str, data: Any):
|
||||||
|
filename += ".json"
|
||||||
|
path = os.path.dirname(filename)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
os.makedirs(path)
|
||||||
|
|
||||||
|
with open(filename, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
def load_data(filename: str):
|
||||||
|
return load_json(os.path.join(DATA_FOLDER, filename))
|
||||||
|
|
||||||
|
def dump_data(filename: str, data: Any):
|
||||||
|
return dump_json(os.path.join(DATA_FOLDER, filename), data)
|
||||||
|
|
||||||
|
def load_guild_data(guild: int, filename: str):
|
||||||
|
return load_data(os.path.join(str(guild), filename))
|
||||||
|
|
||||||
|
def dump_guild_data(guild: int, filename: str, data: Any):
|
||||||
|
return dump_data(os.path.join(str(guild), filename), data)
|
5
hrochobot/utils/json_templates.py
Normal file
5
hrochobot/utils/json_templates.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
TEMPLATES = {
|
||||||
|
'roles.json' : '{"secret_roles": {}}',
|
||||||
|
'messages.json' : '{}',
|
||||||
|
'news.json' : '{}',
|
||||||
|
}
|
45
hrochobot/utils/ksp_utils.py
Normal file
45
hrochobot/utils/ksp_utils.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import feedparser
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
KSP_URL = "https://ksp.mff.cuni.cz"
|
||||||
|
|
||||||
|
def task_link(task_code: str, solution : bool = False):
|
||||||
|
return f"{KSP_URL}/viz/{task_code}/{'reseni' if solution else ''}"
|
||||||
|
|
||||||
|
def catalog(year : Optional[int] = None, tasks : bool = False):
|
||||||
|
year_str = ""
|
||||||
|
if year is not None:
|
||||||
|
year_str = f"year={year}"
|
||||||
|
tasks_str = f"tasks={str(tasks).lower()}"
|
||||||
|
|
||||||
|
page = requests.get(f"{KSP_URL}/api/tasks/catalog?{year_str}&{tasks_str}")
|
||||||
|
return json.loads(page.text)
|
||||||
|
|
||||||
|
def active_deadlines() -> List[Tuple[str, datetime]]:
|
||||||
|
deadlines = []
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
cat = catalog()
|
||||||
|
for series in cat:
|
||||||
|
series_deadlines = [series["deadline"]]
|
||||||
|
if "deadline2" in series:
|
||||||
|
series_deadlines.append(series["deadline2"])
|
||||||
|
series_deadlines = list(map(datetime.fromisoformat, series_deadlines))
|
||||||
|
|
||||||
|
for series_deadline in series_deadlines:
|
||||||
|
if now < series_deadline:
|
||||||
|
deadlines.append((series["id"], series_deadline))
|
||||||
|
break
|
||||||
|
|
||||||
|
return deadlines
|
||||||
|
|
||||||
|
async def ksp_feed() -> feedparser.util.FeedParserDict:
|
||||||
|
return feedparser.parse(f"{KSP_URL}/ksp.feed")
|
||||||
|
|
||||||
|
def strip_id(id_):
|
||||||
|
text = f"{KSP_URL}/"
|
||||||
|
if id_.startswith(text):
|
||||||
|
id_ = id_[len(text):]
|
||||||
|
return id_
|
18
main.py
18
main.py
|
@ -1,18 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
from discord.ext import commands
|
|
||||||
import utils.data as data
|
|
||||||
|
|
||||||
CONFIG = data.load_json("config")
|
|
||||||
|
|
||||||
bot = commands.Bot()
|
|
||||||
|
|
||||||
cogs_list = [
|
|
||||||
'basic',
|
|
||||||
'roles',
|
|
||||||
'ksp',
|
|
||||||
]
|
|
||||||
|
|
||||||
for cog in cogs_list:
|
|
||||||
bot.load_extension(f'cogs.{cog}')
|
|
||||||
|
|
||||||
bot.run(CONFIG["token"])
|
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
py-cord
|
||||||
|
requests
|
||||||
|
feedparser
|
||||||
|
markdownify
|
||||||
|
beautifulsoup4
|
22
setup.py
Normal file
22
setup.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
setuptools.setup(
|
||||||
|
name='hrochbot',
|
||||||
|
version='1.0',
|
||||||
|
description='Discordový robot pro KSP',
|
||||||
|
packages=['hrochobot', 'hrochobot/cogs', 'hrochobot/utils'],
|
||||||
|
scripts=[
|
||||||
|
'bin/hrochobot',
|
||||||
|
],
|
||||||
|
include_package_data=True,
|
||||||
|
zip_safe=False,
|
||||||
|
install_requires=[
|
||||||
|
'py-cord',
|
||||||
|
'requests',
|
||||||
|
'feedparser',
|
||||||
|
'markdownify',
|
||||||
|
'beautifulsoup4',
|
||||||
|
],
|
||||||
|
)
|
|
@ -1,20 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
import json
|
|
||||||
import os.path
|
|
||||||
|
|
||||||
DATA_FOLDER = "data"
|
|
||||||
|
|
||||||
def load_json(filename: str):
|
|
||||||
with open(filename + ".json") as f:
|
|
||||||
content = json.load(f)
|
|
||||||
return content
|
|
||||||
|
|
||||||
def dump_json(filename: str, data: Any):
|
|
||||||
with open(filename + ".json", "w") as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
def load_data(filename: str):
|
|
||||||
return load_json(os.path.join(DATA_FOLDER, filename))
|
|
||||||
|
|
||||||
def dump_data(filename: str, data: Any):
|
|
||||||
return dump_json(os.path.join(DATA_FOLDER, filename), data)
|
|
|
@ -1,4 +0,0 @@
|
||||||
KSP_URL = "https://ksp.mff.cuni.cz"
|
|
||||||
|
|
||||||
def task_link(task_code: str, solution : bool = False):
|
|
||||||
return f"{KSP_URL}/viz/{task_code}/{'reseni' if solution else ''}"
|
|
Loading…
Reference in a new issue