Skip to content

Repository files navigation

Discord NAS Bot

A discord bot for personal use (NAS bot)

How to use

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.

Example usage

This solution could be used with NAS after linking with it, e.g. NFS

mount -t nfs <addr>:/ /mnt/nas

Then you can make some integration scripts triggered by Discord NAS Bot like ...

  • Reorganizing files
  • Print file status
  • Download some files from given arguments

etc ...

Deploy with Docker

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.

Plugin

This discord bot is extensible with plugins. Below is the usage.

Add a plugin

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.

Run a plugin on a schedule

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:

  • command is optional — a plugin with only cron is schedule-only.
  • A scheduled run has no invoking message, so ctx.send posts to the first channel named in channel. With channel unset there is nowhere to post and the output is logged instead.
  • Scheduled runs call handler(ctx) with no arguments. Override async 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.

Using dependencies

Every dispatcher receives the shared dependencies from depend.py as self.dep:

  • self.dep.web — web accessor with read(url), read_html(url), download(path, url), archive(path, urls)
  • self.dep.webproxy — same interface, but routed through the DPI proxy; None unless PROXY_HOST (and optionally PROXY_PORT) is set in .env
  • self.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 with stdout)
  • plugins/ex_proxy_downloader!ex_proxy_download <url> [filename|stdout], same but through the DPI proxy (fails if PROXY_HOST is not configured)
  • plugins/plugin_downloader!plugin_download <name> <handler.py url> installs a plugin's handler.py from a URL

Add a private plugin

For putting a private plugin, put the plugin under plugins_priv/ folder. This won't be tracked by git.

Enabling a plugin

Just putting a plugin will be fine.

Testing a plugin

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.

Disabling a plugin

Add a environment variable DISABLED_PLUGINS= in .env file like following:

DISABLED_PLUGINS=bot,downloader

Development

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.

About

A personal discord bot

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages