A self-hostable Telegram community bot you can spin up by editing a single YAML file. Chat admins
customize their own chat via an inline /config menu — no web UI, no Pubky identity required to run
it.
- One file configures the bot.
config.yamllists every feature with sensible defaults. Chat admins toggle features per-chat via/config. - Sandboxed services. Each feature (help, meetups, triggerwords, url cleaner, event creator, …) runs in a Deno subprocess with zero permissions by default.
- Optional Pubky. Features that publish events to a Pubky homeserver are auto-disabled unless you provide a keypair. Everything else runs without it.
- Pre-built profiles.
configs/general-purpose.example.yamlfor anyone,configs/dezentralschweiz.example.yamlfor the Swiss bitcoin community.
mkdir loombot && cd loombot
curl -O https://raw.githubusercontent.com/gillohner/loombot/master/docker-compose.yml
cat > .env <<EOF
BOT_TOKEN=123456:your-telegram-bot-token
PROFILE=general-purpose
BOT_ADMIN_IDS=your_telegram_user_id
EOF
docker compose up -dThat's it — the container copies a profile on first boot, writes config.yaml into the
loombot_data volume, and starts polling. See Docker below for the full env-var
reference, the dezentralschweiz profile, and Pubky setup.
git clone https://github.com/gillohner/loombot
cd loombot
cp configs/general-purpose.example.yaml config.yaml
cp .env.example .env.local
# Put your BOT_TOKEN into .env.local, then:
deno task config:check
deno task devInvite the bot to your Telegram group. As a chat admin, run /config to pick which features are
active in that chat.
- Deno 1.45+ (only for
From source/ development — the Docker image ships Deno internally) - A Telegram bot token from @BotFather
- Your own Telegram user id (for
BOT_ADMIN_IDS) — DM @userinfobot and it replies with your numeric id - (Optional) A Pubky identity if you want the
event_creatorfeature or any other service that writes to a homeserver. See Pubky setup below.
After adding the bot to a group (via /addme or Telegram's "Add to Group"), make it an admin with
at least:
- Delete Messages — the bot trims its own old inline menus (
replaceGroup), deletes URL- cleaner trigger messages, and optionally auto-deletes help/meetups replies viamessageTtl - Pin Messages — needed for the periodic meetups broadcast (
periodicPin: true) - Read Messages — enabled by default, but if you created the bot a while ago, double-check that
privacy mode is off via BotFather →
/mybots→ your bot → Bot Settings → Group Privacy → "Turn off"; otherwise the bot only sees commands addressed to it directly, not arbitrary messages, which breaks the listener services (triggerwords,url_cleaner,new_member)
- Your user id: DM @userinfobot → it replies with your numeric id
- A group chat id: add @getidsbot to the group, run
/start, copy thechat_idfrom its reply, then remove the helper bot. Group ids look like-1001234567890(negative, and supergroups have the-100prefix)
These ids go into BOT_ADMIN_IDS and PUBKY_APPROVAL_GROUP_CHAT_ID in your env file.
You only need this if you enabled a Pubky-gated feature in config.yaml (e.g. event_creator) or
are running the dezentralschweiz profile. Features gated on requiresPubky: true in the service
registry are auto-disabled when pubky.enabled: false, so skipping this section is fine for the
default general-purpose profile.
Pick one of:
- Pubky Ring (mobile app, easiest) — create an identity, then export a
.pkarrrecovery file from the settings menu. Pick a strong passphrase during export and write it down — you'll need it below. - Pubky CLI — follow the repo's readme to generate and export a keypair. The CLI is a better fit if you already manage secrets via your shell.
Either way you end up with a .pkarr file on disk.
A fresh recovery file is an identity but has no home yet. Before the bot can write events through it you need to sign in to a Pubky homeserver once (Pubky Ring does this for you during identity creation). If you used the CLI, follow the repo's "sign up" instructions against a homeserver of your choice. Public homeserver lists live at pubky.org.
Local dev:
mkdir -p secrets
mv ~/Downloads/operator.pkarr secrets/operator.pkarr
chmod 600 secrets/operator.pkarrThen in .env.local:
PUBKY_PASSPHRASE=<the passphrase you set at export time>And in config.yaml:
pubky:
enabled: true
recovery_file: ./secrets/operator.pkarr
passphrase_env: PUBKY_PASSPHRASE
approval_group_chat_id: -1001234567890 # your admin group chat idDocker: see Supplying a Pubky recovery file below — you can either bind-mount the file or pass it as a base64 env var.
If you want /meetups to show events, you need at least one calendar URI. Create one at
eventky.app — sign in with a Pubky identity, create a calendar, and copy its
pubky:// URI from the share sheet. Add it to config.yaml under
features.meetups.config.calendars as a curated option chat admins can pick from. The
/meetup_erstellen / event_creator service writes events through your bot's keypair to whatever
calendar(s) the user selects.
Three layers, merged at dispatch time:
config.yaml (operator defaults)
│
▼
chat-type default ← `dms:` / `groups:` fields per feature
│
▼
chat override ← what chat admins set via /config (in SQLite)
│
▼
resolved snapshot ← what the dispatcher actually runs
Each entry under features: is a command or listener the bot exposes. Its shape:
features:
meetups:
service: meetups # name from src/services/registry.ts
groups: true # enabled by default in groups
dms: true # enabled by default in DMs
lock: false # true = chat admins can't toggle it
config: { … } # passed to the service as serviceConfig
datasets: { … } # passed as datasets (optional)
allow_external_calendars: true # meetups-specificThe feature id (the key above, e.g. meetups) is also the command name for command-style
services. Duplicate service instances with different ids are allowed — the Dezentralschweiz profile
has three triggerwords features (pocketethereum, shitcoin_alarm, shitcoiner_alarm) each with
its own trigger list.
cp configs/general-purpose.example.yaml config.yaml # or another profile
cp .env.example .env.localEdit config.yaml — walk through each feature, set sensible defaults. For meetups add the
calendar URIs you want chat admins to be able to pick from. For pubky.enabled: true profiles, fill
in recovery_file and approval_group_chat_id.
Edit .env.local and set BOT_TOKEN. If pubky.enabled: true, also set PUBKY_PASSPHRASE.
Validate the config without starting the bot:
deno task config:check
# or validate a profile in-place:
deno task config:check ./configs/dezentralschweiz.example.yamldeno task dev # polling mode, auto-reloads on file change
deno task serve # webhook mode (WEBHOOK=1 set by the task)Invite the bot into a Telegram group, make it admin, send /start. The bot registers the command
list automatically and again on every /config use.
Chat admins (Telegram admins, or users listed in bot.admin_ids) run /config in the chat and get
an inline-keyboard menu:
- 🧩 Features — toggle which features are on in this chat. Locked features (
lock: trueinconfig.yaml) don't appear. - 📅 Calendars — pick which operator-curated meetup calendars to show in this chat. If the
operator set
allow_external_calendars: true, admins can also add a freeformpubky://…/calendars/…URI. - 📣 Periodic broadcast — toggle the weekly auto-post of upcoming events, and shift its day, hour, timezone, range, and pin behaviour per-chat. Use Send preview now to test settings immediately.
- 👋 Welcome message — override the default new-member greeting for this chat only. Supports
{display_name},{username},{first_name},{last_name},{user_id}placeholders.
Per-chat settings live in the local SQLite database (bot.sqlite). They persist across restarts but
are not shared between deployments — each host has its own overrides.
Pre-built configs in configs/:
-
general-purpose.example.yaml—pubky.enabled: false. Ships withhelp,hello,meetups(off until you add calendars or flipallow_external_calendars),new_member,triggerwords(uses the service's built-in eth/btc joke dataset),url_cleaner(built-in alt-frontend mappings). Works with just aBOT_TOKEN. -
dezentralschweiz.example.yaml— The Swiss bitcoin community bot.pubky.enabled: true, two curated calendars, German welcome messages,/meetup_erstellenevent creator, full/linkscategory list, and three triggerwords listeners (pocketethereum, shitcoin alarm, shitcoiner alarm) with all responses and trigger words inlined. Requires a Pubky recovery file to enable event publishing.
Copy either to config.yaml as your starting point.
The container ships as a single image that picks one of the bundled profiles on first boot and layers env-var overrides on top. No file editing required for a vanilla install.
BOT_TOKEN=123456:your-telegram-bot-token
PROFILE=general-purpose # or: minimal | dezentralschweiz
BOT_ADMIN_IDS=12345678 # comma-separated Telegram user idsThen docker compose up -d using the docker-compose.yml at the repo root.
One named volume (loombot_data) maps to /data inside the container:
/data/
├── config.yaml # copied from configs/${PROFILE}.example.yaml on first boot
├── bot.sqlite # per-chat overrides + pending pubky writes
└── secrets/
└── operator.pkarr # optional pubky recovery file
The profile copy only happens when config.yaml is missing, so editing it in the volume survives
restarts. If you prefer editing on the host, swap the named volume for a bind mount in
docker-compose.yml:
volumes:
- ./data:/data| Variable | Purpose | Default |
|---|---|---|
BOT_TOKEN |
Telegram bot token from @BotFather | required |
PROFILE |
Which profile to copy on first boot | general-purpose |
BOT_ADMIN_IDS |
Comma-separated Telegram user ids (super-admins everywhere) | empty |
LOCK_DM_CONFIG |
1 → only super-admins can /config in DMs |
0 |
LOG_MIN_LEVEL |
debug / info / warn / error |
info |
PUBKY_ENABLED |
1 → enable the Pubky writer |
0 |
PUBKY_PASSPHRASE |
Passphrase for the pkarr recovery file | empty |
PUBKY_APPROVAL_GROUP_CHAT_ID |
Telegram chat id for write approvals | unset |
PUBKY_APPROVAL_TIMEOUT_HOURS |
Hours before pending writes expire | 24 |
PUBKY_RECOVERY_FILE |
Path to .pkarr file inside the container |
/data/secrets/operator.pkarr |
PUBKY_RECOVERY_FILE_B64 |
Base64-encoded .pkarr — written to PUBKY_RECOVERY_FILE on first boot |
unset |
All env vars except BOT_TOKEN and PUBKY_PASSPHRASE are applied as overrides on top of whatever
is in config.yaml every boot, so you can swap admin ids or toggle Pubky without editing files.
Content-heavy fields — trigger words, link categories, curated calendars — stay in config.yaml
inside the volume. Either pre-seed it by copying a profile locally and mounting it, or shell in and
edit:
docker exec -it loombot sh -c 'vi /data/config.yaml'
docker compose restart loombotgeneral-purpose(default) — works with just aBOT_TOKEN. No Pubky writing, built-in joke + alt-frontend datasets.dezentralschweiz— full Swiss bitcoin community bot. SetPUBKY_ENABLED=1, supply a recovery file, and setPUBKY_APPROVAL_GROUP_CHAT_ID.minimal— the tiny top-levelconfig.example.yaml, useful as a start-from-scratch skeleton.
Two options for dezentralschweiz or any PUBKY_ENABLED=1 setup:
File mount (easiest when you control the filesystem):
volumes:
- ./loombot-data:/data
# then place your file at ./loombot-data/secrets/operator.pkarr on the hostBase64 env var (for platforms like Umbrel/Start9 where file mounts are awkward):
base64 -w0 ~/operator.pkarr # copy the outputPaste into .env:
PUBKY_RECOVERY_FILE_B64=<base64 blob>The entrypoint writes it to /data/secrets/operator.pkarr with chmod 600 on first boot only;
thereafter it's ignored so rotating means deleting the volume file first.
The same image + compose file works under any platform that speaks Docker Compose. Each platform provides its own manifest format that wraps a compose file and exposes env vars as a setup form:
- Umbrel — create an
umbrel-app.ymlbeside the compose file listing the env vars. Umbrel renders a form and persists the values across restarts. See Umbrel app docs. - Start9 / StartOS — build an
s9pkpackage whose manifest declares the same env vars as structured config fields. StartOS renders a proper form and injects them into the container. - TrueNAS Apps — use "Custom App" → Docker Compose and paste the contents of
docker-compose.yml. TrueNAS will prompt for the env vars defined in the compose file.
The source of truth in every case is docker-compose.yml at the repo root and the env-var table
above. Adding a platform manifest is ~20 lines of YAML per platform — contributions welcome.
loombot/
├── config.yaml # references ./secrets/<name>.pkarr
├── .env.local # BOT_TOKEN, PUBKY_PASSPHRASE
└── secrets/
└── operator.pkarr # Pubky recovery file, chmod 600
secrets/is gitignored — never commits..env.localis gitignored — never commits.- Passphrase stays in
.env.localunderPUBKY_PASSPHRASE; the config file only stores the path (pubky.recovery_file) and the env var name (pubky.passphrase_env, defaultPUBKY_PASSPHRASE). - On servers,
chmod 600 secrets/*.pkarrand run the bot as a dedicated user. In Docker, mount./secretsas a read-only volume and injectPUBKY_PASSPHRASEvia the orchestrator's secret manager instead of committing.env.localinto the image.
Generate a Pubky recovery file with Pubky Ring or the Pubky CLI; register it with a homeserver before starting the bot.
Super-admins (users in bot.admin_ids) can run /config anywhere, including DMs. Everyone else
defers to Telegram's chat-admin status for their group.
bot:
admin_ids: [123456789] # your Telegram user id(s)
lock_dm_config: false # true = only super-admins can configure DMs- Create the service file at
packages/core_services/<name>/service.tsusingdefineService()from@sdk/mod.ts. - Register it in
src/services/registry.tswith its entry path and kind (single_command|command_flow|listener), plus anynet:allow- list orrequiresPubky: true. - Add a
features.<featureId>block toconfig.yamlreferencing the registry name. - Run
deno task config:check && deno task dev.
Services talk to the bot via stdin/stdout JSON and return ServiceResponse objects — see
packages/sdk/mod.ts and any existing service for examples.
| Service | Kind | Default command | Notes |
|---|---|---|---|
help |
single_command | /help |
Configurable message + command list |
simple_response |
single_command | feature id | One-line reply; run multiple for /hello, /about, etc. |
links |
command_flow | /links |
Categorized link menu with inline keyboard |
meetups |
command_flow | /meetups |
Reads Pubky eventky calendars; today/week/2weeks/30days |
event_creator |
command_flow | feature id | Multi-step event creation → Pubky write (requires pubky) |
new_member |
listener | — | Welcomes new group members |
triggerwords |
listener | — | Fires on keyword matches; multiple instances allowed |
url_cleaner |
listener | — | Strips trackers, suggests privacy-friendly alt frontends |
- "Config file not found" — copy an example to
config.yaml, or setCONFIG_FILEin.env.local. - "unknown service" — the
service:value doesn't match anything insrc/services/registry.ts. Typos are the usual cause. - Bot doesn't respond to messages in a group — Telegram bots have privacy mode on by default,
which only delivers messages that mention or reply to the bot. Turn it off: BotFather →
/mybots→ your bot → Bot Settings → Group Privacy → "Turn off". Then kick and re-invite the bot to the group for the change to take effect. - Periodic broadcast never fires in a group — check that at least one admin has opened
/configin the chat at some point (the scheduler only enumerates chats the bot has registered as "known"). Any incoming message since commit71af1cealso auto-registers the chat, so this should be a non-issue on recent builds. Also verify the bot has Pin Messages permission ifperiodicPin: true. - Pubky-dependent features silently disabled — check the
config.loadedstartup log: ifpubkyEnabled: false, features withrequiresPubky: truein the registry are filtered out. Flippubky.enabled: trueinconfig.yamland setPUBKY_PASSPHRASEin.env.local. - Pubky writes fail — confirm
recovery_filepath exists, the file is readable,PUBKY_PASSPHRASEdecrypts it, andapproval_group_chat_idpoints at a Telegram group the bot is in. /configdoesn't appear — make sure you're a Telegram admin in the group, or that your user id is inbot.admin_ids.- Per-chat overrides not picked up — snapshots auto-clear on restart, but for a running process
you may need to wait up to 10 s for the in-memory cache to expire.
/configactions invalidate the cache immediately. - Validating config inside a running container:
docker exec -it loombot deno run --allow-read=/data --allow-env \ --allow-import=deno.land,jsr.io,registry.npmjs.org,cdn.npmjs.org \ /app/scripts/validate-config.ts /data/config.yaml - "No such image" when running
docker compose up—docker-compose.ymldefaults toghcr.io/gillohner/loombot:latest. If that tag isn't published yet, build locally by uncommenting thebuild: .line indocker-compose.yml. - Pinning a Docker version — the shipped compose file uses
:latestfor convenience. Replace with a tagged release (e.g.ghcr.io/gillohner/loombot:v0.1.0) before deploying to production so upgrades are deliberate.
Any process manager works. PM2 example:
pm2 start --name loombot "deno task serve"
pm2 saveOn upgrade:
git pull
deno task config:check
pm2 restart loombotThe bot.sqlite file is the per-chat override database — back it up regularly if you care about
preserving chat admin customizations.
deno task dev # polling mode with --watch
deno task test # run all tests
deno task config:check
deno lint
deno fmt # tabs, 100-char line widthProject layout and architecture notes live in CLAUDE.md.