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):
|
||||
self.bot = bot
|
||||
|
||||
@discord.slash_command()
|
||||
@discord.slash_command(description="Greets the world.")
|
||||
async def sayhello(self, ctx):
|
||||
await ctx.respond('Hello world!')
|
||||
|
||||
@discord.slash_command()
|
||||
@discord.slash_command(description="Sends the bot's latency.")
|
||||
async def ping(self, ctx):
|
||||
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