A discord bot for personal use (NAS bot)
Provide necessary tokens from .env.template, invite your bot in the discord server, then turn on the script as follows:
# Run uv sync --frozen if package is not yet initialized
uv run python main.py
In the discord channel, say !ping, then the bot will respond.
Say !bot to list every usable command (with its channel restriction and description)
and every active cron schedule with its next run time. This one is built in rather than
a plugin, since it needs the scheduler that plugins are not given.
This solution could be used with NAS after linking with it, e.g. NFS
mount -t nfs <addr>:/ /mnt/nasThen you can make some integration scripts triggered by Discord NAS Bot like ...
- Reorganizing files
- Print file status
- Download some files from given arguments
etc ...
The image bundles SpoofDPI as a local proxy, so
self.dep.webproxy works out of the box — no proxy setup on the host.
docker compose up -d --build
or without compose:
make docker-build
make docker-run
Credentials are never baked into the image; .env is passed at runtime
(--env-file .env / compose env_file). Downloads are written to the /data
volume, which compose maps to ./downloads.
Proxy behaviour is controlled by these variables:
| Variable | Default | Meaning |
|---|---|---|
SPOOFDPI_ENABLED |
1 |
start the bundled proxy and point the bot at it |
SPOOFDPI_LISTEN_ADDR |
127.0.0.1:8080 |
address the bundled proxy listens on |
SPOOFDPI_LOG_LEVEL |
info |
spoofdpi log level |
SPOOFDPI_DNS_MODE |
https |
proxy-side DNS; https means DNS-over-HTTPS |
SPOOFDPI_DNS_HTTPS_URL |
https://1.1.1.1/dns-query |
proxy-side DoH endpoint |
PROXY_HOST / PROXY_PORT |
the bundled proxy | set to use a different proxy instead |
DNS-over-HTTPS is on at both layers: the bot resolves its own lookups over DoH
(DOH_URL), and the proxy resolves the target hostnames over DoH
(SPOOFDPI_DNS_MODE=https), so proxied traffic makes no plaintext DNS queries either.
Set SPOOFDPI_ENABLED=0 without PROXY_HOST to run with no proxy at all; the
proxy-only commands then report Proxy is not ready instead of failing silently.
This discord bot is extensible with plugins. Below is the usage.
Put a python file handler.py under plugins/ folder like below:
# example: plugins/ping/handler.py
# Below bot will be triggered with `!ping` command, only under `bot` channel.
from botcmd.dispatcher import DiscordCommandDispatcher
class PingDispatcher(DiscordCommandDispatcher):
command = "ping"
channel = ["bot"]
async def handler(self, ctx):
await ctx.send(f"🏓 Pong! {round(self.bot.latency * 1000)}ms")Leave channel unset to allow the command in every channel.
Declare the handler as async def handler(self, ctx, *args) to receive the command
arguments (space-split, quoted strings kept together):
# example: plugins/echo/handler.py
# `!echo hello world` -> the bot replies: You typed "hello world"
from botcmd.dispatcher import DiscordCommandDispatcher
class EchoDispatcher(DiscordCommandDispatcher):
command = "echo"
async def handler(self, ctx, *args):
await ctx.send(f'You typed "{" ".join(args)}"')A handler declared as async def handler(self, ctx) simply ignores any arguments.
Set a 5-field cron expression as a class field and the plugin also runs by itself:
# example: plugins/ex_cron/handler.py
# runs every 30 minutes, and on demand with `!ex_heartbeat`
from datetime import datetime
from botcmd.dispatcher import DiscordCommandDispatcher
class ExCronDispatcher(DiscordCommandDispatcher):
command = "ex_heartbeat"
cron = "*/30 * * * *"
channel = ["bot"]
async def handler(self, ctx):
now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
await ctx.send(f"💓 alive at {now}")Notes:
commandis optional — a plugin with onlycronis schedule-only.- A scheduled run has no invoking message, so
ctx.sendposts to the first channel named inchannel. Withchannelunset there is nowhere to post and the output is logged instead. - Scheduled runs call
handler(ctx)with no arguments. Overrideasync def scheduled(self, ctx)for behaviour specific to scheduled runs. - A failing run is logged and the schedule continues; an invalid expression is reported at startup and only that schedule is skipped.
- Times follow the machine's local timezone — containers are UTC unless you set
TZ.
Every dispatcher receives the shared dependencies from depend.py as self.dep:
self.dep.web— web accessor withread(url),read_html(url),download(path, url),archive(path, urls)self.dep.webproxy— same interface, but routed through the DPI proxy;NoneunlessPROXY_HOST(and optionallyPROXY_PORT) is set in.envself.dep.config— the app config
read_html(url) returns a BeautifulSoup
document parsed with lxml, so plugins can scrape without importing a parser themselves:
soup = await self.dep.web.read_html(url)
title = soup.title.get_text(strip=True)
links = [a["href"] for a in soup.select("a[href]")]See plugins/ex_scraper (!ex_scrape <url>) for a working example. Pass
parser="html.parser" to use the stdlib parser instead of lxml.
Both send desktop-Chrome request headers (utils.web.BROWSER_HEADERS) instead of
aiohttp's default Python/3.x aiohttp/3.y User-Agent, so sites don't reject them as a
crawler. Pass headers={...} to Web/WebProxy to add or override individual entries.
Both resolve hostnames over DNS-over-HTTPS (DOH_URL, default
https://1.1.1.1/dns-query); set DOH_URL=off to use the system resolver instead.
For webproxy the DoH lookups are sent through the proxy as well, so they get the
same DPI treatment as the requests. Note that webproxy hands the target hostname to
the proxy, which resolves it itself — see the Docker section for that setting.
Working examples:
plugins/ex_downloader—!ex_download <url> [filename|stdout]downloads a file (or shows its content withstdout)plugins/ex_proxy_downloader—!ex_proxy_download <url> [filename|stdout], same but through the DPI proxy (fails ifPROXY_HOSTis not configured)plugins/plugin_downloader—!plugin_download <name> <handler.py url>installs a plugin'shandler.pyfrom a URL
For putting a private plugin, put the plugin under plugins_priv/ folder. This won't be tracked by git.
Just putting a plugin will be fine.
Put a test_handler.py next to the plugin's handler.py. Use load_plugin_handler to
import the handler under test:
# example: plugins/ping/test_handler.py
from types import SimpleNamespace
from botcmd.testing import load_plugin_handler
handler = load_plugin_handler(__file__)
async def test_ping():
sent = []
async def send(msg):
sent.append(msg)
dispatcher = handler.PingDispatcher(SimpleNamespace(latency=0.123))
await dispatcher.handler(SimpleNamespace(send=send))
assert sent == ["🏓 Pong! 123ms"]uv run pytest collects every plugin's tests (including plugins_priv/) together with
the framework tests under tests/. Async test functions work out of the box.
Add a environment variable DISABLED_PLUGINS= in .env file like following:
DISABLED_PLUGINS=bot,downloader
Lint and format with ruff, run tests with pytest:
uv run ruff check .
uv run ruff format .
uv run pytest
CI runs all of the above on every push and pull request.