- TypeScript 99.7%
- Dockerfile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Build and Push Container Image / build-and-push-1 (push) Successful in 49s
Build and Push Container Image / build-and-push (push) Successful in 0s
Build and Push Container Image / deploy-1 (push) Successful in 4s
Build and Push Container Image / deploy (push) Successful in 0s
|
||
| .forgejo/workflows | ||
| .zed | ||
| scripts | ||
| src | ||
| .env.example | ||
| .fallowrc.json | ||
| .gitignore | ||
| AGENTS.md | ||
| Containerfile | ||
| deno.json | ||
| deno.lock | ||
| fallow.schema.json | ||
| lefthook.yml | ||
| LICENSE | ||
| README.md | ||
| renovate.json | ||
irc-greeter-bot
A Deno IRC bot that greets newcomers to a channel and provides additional channel awareness features. While greeting remains the bot's primary function, it has evolved into a multi-purpose helper that also posts a daily XKCD comic, announces RSS/Atom feed updates, and watches Forgejo repository activity. The bot remembers who it has already greeted by persisting nicks to a JSON file on disk, so the same person is not re-greeted -- even after a restart.
Requirements
Features
- Channel greeting — Greets newcomers to the home channel with a
configurable message, remembers who was greeted across restarts. Bot-aware:
nicks the server flags with the IRCv3
bottag, or that re-verify as bots via aWHOIScheck afterIRC_GREET_VERIFY_DELAY_MS(looking for the IRCv3 Bot ModeRPL_WHOISBOTline), are never greeted. - Daily XKCD comic — Posts a random XKCD comic's picture link at local midnight every day (de-duplicated against history so the same comic is not re-shown)
- RSS/Atom feed announcements — Watches feeds and announces new items to dedicated channels with polite, throttled polling
- Forgejo activity announcements — Monitors Forgejo repositories and announces new issues, pull requests, and other activity
- Network resilience — Automatic reconnection with backoff, survives server restarts and network outages
- Authentication support — NickServ, SASL PLAIN, SASL EXTERNAL (client cert)
- HTTP notification webhook — A small
POST /v1/sendendpoint (bearer-token authenticated) that pushes a one-off message to any channel the bot has already joined, e.g. from another service or a cron job
Configuration
Configuration is loaded from a .env file and the process environment.
Process environment variables take precedence over .env values. Required
variables are marked as such in the table below; missing or empty required
values are fatal errors. Optional variables have documented defaults.
Copy .env.example to .env to get started:
cp .env.example .env # then edit .env
| Variable | Description |
|---|---|
IRC_SERVER |
IRC server hostname. |
IRC_PORT |
IRC server port (1-65535). |
IRC_TLS |
Connect with TLS (true/false). |
IRC_NICK |
Nickname the bot registers as. |
IRC_REALNAME |
The bot's realname (gecos). Sent in the USER handshake and re-asserted with an IRCv3 SETNAME after registration on servers that support it (some servers ignore the handshake value). Optional; defaults to HorseBot. |
IRC_AVATAR |
URL set as the bot's avatar IRCv3 metadata (draft/metadata-2) after registration, so IRC clients can show an avatar for the bot. Optional; unset = not set. |
IRC_GREETING_CHANNEL |
The greeting (home) channel: newcomers are greeted here and the daily XKCD comic is posted here. The bot also joins every feed/repo channel automatically. Required. |
IRC_GREETING |
Greeting template; {nick} substituted. |
IRC_GREET_VERIFY_DELAY_MS |
Delay (ms) between a join and re-verifying the newcomer is not a bot (via a WHOIS check for the IRCv3 Bot Mode RPL_WHOISBOT line) before greeting it, so a bot that registers as a bot a moment after joining is caught. 0 re-verifies immediately. Optional; defaults to 10000. |
IRC_STORE_PATH |
Path to the file that persists greeted nicks. Optional; defaults to greeted-nicks.json. |
IRC_PASSWORD |
Account password for NickServ/SASL auth. Optional; unset = anonymous. |
IRC_AUTH_METHOD |
Auth method when IRC_PASSWORD is set: NickServ (default), sasl, saslThenNickServ, or saslExternal. |
IRC_FEEDS |
Comma-separated "<feed-url> <#channel>" pairs; each feed announces into its own dedicated channel. Optional; unset = disabled. |
IRC_FEED_INTERVAL_MIN |
Minimum polling interval in minutes (>= 5, default 60). Feed-declared update frequencies may raise it. |
IRC_FEED_CHECKPOINT_PATH |
Path to the file that persists per-feed checkpoints. Optional; defaults to feed-checkpoints.json. |
IRC_FEED_USER_AGENT |
User-Agent sent to feed hosts. Optional; defaults to an identifiable bot string. |
IRC_FEED_MESSAGE |
Announcement template with {title}, {link}, {feed} placeholders. Optional; defaults to a sensible message. |
IRC_FEED_MAX_ITEMS |
Max items announced per feed per poll cycle (newest kept). Optional; defaults to 3. |
IRC_FORGEJO_INSTANCES |
Comma-separated instance names (uppercase letters, digits, _, -); each selects an IRC_FORGEJO_<NAME>_* variable family below. Optional; unset = disabled. |
IRC_FORGEJO_<NAME>_API_URL |
Base URL of that instance's Forgejo server. Required per instance. |
IRC_FORGEJO_<NAME>_TOKEN |
Access token for that server (scopes read:repository + read:issue); sent as an Authorization header, never persisted. Required per instance. |
IRC_FORGEJO_<NAME>_REPOS |
Comma-separated "<owner/repo> <#channel>" pairs to watch on that server; each repo announces into its own dedicated channel. An optional third +-joined field lists op_types to ignore for that repo (e.g. owner/repo #chan commit_repo+push_tag). Required per instance. |
IRC_FORGEJO_INTERVAL_MIN |
Shared default: minimum polling interval in minutes (>= 5, default 60); overridable per instance. |
IRC_FORGEJO_LIMIT |
Shared default: page size for the activities API (default 30); overridable per instance. |
IRC_FORGEJO_MESSAGE |
Shared default announcement template ({title}, {link}, {repo}, {user}, {instance}). Defaults to {title}; overridable per instance. |
IRC_FORGEJO_RELEASE_NOTES_MESSAGE |
Shared default second-message template ({title}, {link}, {repo}, {user}, {instance}, {notes}) posted for releases that have notes. Empty disables. |
IRC_FORGEJO_MAX_ITEMS |
Shared default: max events announced per repo per poll cycle (newest kept, default 3); overridable per instance. |
IRC_FORGEJO_IGNORE_OP_TYPES |
Shared default: comma-separated op_types to skip announcing (e.g. commit_repo to ignore pushes, push_tag for tag pushes); empty = announce everything. Overridable per instance. |
IRC_FORGEJO_USER_AGENT |
Shared default User-Agent sent to the API; overridable per instance. |
IRC_FORGEJO_<NAME>_<KNOB> |
Per-instance override for any shared knob: _INTERVAL_MIN, _LIMIT, _MAX_ITEMS, _MESSAGE, _RELEASE_NOTES_MESSAGE, _IGNORE_OP_TYPES, _USER_AGENT; plus _STORE_PATH (per-instance only, defaults to forgejo-activity-<name>.json, must be unique). |
IRC_XKCD_STORE_PATH |
Path to the file that persists the already-shown XKCD comic numbers. Optional; defaults to xkcd-shown.json. |
IRC_XKCD_TIME |
Local time of day (HH:MM, 24-hour) at which the daily comic is posted; hour 0-23, minute 0-59. Optional; defaults to 00:00 (local midnight). Malformed/out-of-range values are a fatal startup error. |
IRC_WEBHOOK_TOKEN |
Bearer token for the POST /v1/send HTTP webhook. Optional; unset = webhook disabled (no server started). |
IRC_WEBHOOK_HOST |
Interface the webhook binds to (default 127.0.0.1). |
IRC_WEBHOOK_PORT |
Port the webhook listens on (default 8080). |
IRC_WEBHOOK_MAX_MESSAGE_BYTES |
Maximum UTF-8 byte length of an incoming message (default 300; IRC lines are capped at 512 bytes including CRLF). |
NickServ / SASL registration
The bot supports authenticating against a registered account using the built-in
mechanisms provided by @irc/client. Set
IRC_PASSWORD and pick an IRC_AUTH_METHOD:
| Method | Description |
|---|---|
NickServ |
Non-standard NickServ IDENTIFY after connect. Default. Requires IRC_PASSWORD. |
sasl |
SASL PLAIN during registration; aborts if SASL fails. Requires IRC_PASSWORD. |
saslThenNickServ |
SASL PLAIN, falling back to NickServ. Requires IRC_PASSWORD. |
saslExternal |
SASL EXTERNAL via a TLS client certificate (no password). Requires IRC_TLS=true plus a client cert. |
sasl / saslThenNickServ are recommended on networks that support them (e.g.
Libera.Chat) -- they authenticate before the bot is visible on the network,
avoiding enforcer kills on a nick-protected account.
Example (SASL PLAIN):
IRC_PASSWORD=your-account-password
IRC_AUTH_METHOD=sasl
Leave IRC_PASSWORD unset (or empty) to connect anonymously -- in that case
IRC_AUTH_METHOD is ignored.
Note:
saslExternalauthenticates with a TLS client certificate rather than a password. Certificate file paths are not currently exposed via env vars; use plainsaslfor password-based auth. Open an issue if you needsaslExternalwired up.
Avatar metadata
Set IRC_AVATAR to a URL and the bot records it as its avatar IRCv3
metadata right after
registration, so IRC clients (and services) can show an avatar for the bot. The
metadata extension is still a draft, so the bot negotiates the
draft/metadata-2 capability (which depends on batch, already negotiated) and
only sends METADATA * SET avatar :<url> when the server ACKed it. The
confirmation (RPL_KEYVALUE) and any FAIL METADATA … rejection are logged.
Leave IRC_AVATAR unset (or empty) to skip the feature entirely.
Daily XKCD comic
To give the bot a bit more presence in the channel, it shares an XKCD comic
once a day at a configurable local time -- by default once a day at 00:00
(the server's local time), but the fire time is overridable with IRC_XKCD_TIME
(e.g. IRC_XKCD_TIME=09:30 to post at 09:30 local) so the comic lands when the
channel is actually active rather than the middle of the night. Whenever it
fires, it posts a random comic's picture link to the greeting (home)
channel. IRC clients that render image URLs inline will display the comic
directly. The comic's alt text (the hover/title text, often the actual
punchline) is appended on its own line, so the joke survives even on clients
that don't render the image. This is an intentionally lossy reminder rather than
a per-boot announcement, so (re)starting the bot no longer spams the channel.
The daily timer is started once at startup and self-reschedules each day, always targeting the live connection so that reconnects are picked up automatically; if the bot happens to be disconnected at the configured time the comic is simply skipped and never crashes the process.
No repeats (without a shuffle bag)
The randomness comes from XKCD's own random-comic endpoint
(https://c.xkcd.com/random/comic/): the bot follows the redirect to land on a
random comic, then fetches that comic's info.0.json for the direct image URL
(img). This is not a curated list or a non-repeating shuffle bag -- the
draw is server-side. To avoid re-showing a comic, the comic numbers already
posted are persisted to a JSON file (IRC_XKCD_STORE_PATH, default
xkcd-shown.json); if a random draw lands on an already-shown number the bot
simply asks for another, bounded by a small retry budget. With ~3,300 comics the
same one is very unlikely to appear twice, and the persisted set keeps restarts
from immediately repeating a just-shown comic.
RSS/Atom feed announcements
The bot can watch a list of RSS 2.0, RSS 1.0/RDF, or Atom 1.0 feeds and post a
short announcement into that feed's dedicated channel whenever a new item
appears. Set IRC_FEEDS to a comma-separated list of "<url> <#channel>" pairs
-- the channel is mandatory and each feed only announces into its own.
Feed parsing is done by a small, dependency-free in-tree XML parser
(src/xml.ts + src/feed.ts) rather than a
third-party library, in keeping with how the rest of the bot is hand-rolled. Any
malformed feed is caught and retried on the next poll -- it never crashes the
bot.
Being polite to feed hosts
Politeness is a first-class concern, mirroring the RSS profiling best practices:
- Conditional GET -- every request sends the stored
ETag(If-None-Match) andLast-Modified(If-Modified-Since); an unchanged feed returns304and is skipped entirely. - Per-feed scheduling that honors the feed's own cadence -- RSS
<ttl>, thesy:updatePeriodmodule, and HTTPCache-Control: max-age/Expiresall raise the polling interval. The configuredIRC_FEED_INTERVAL_MINis a floor; feed hints can only make the bot slower, never faster. - Hard 5-minute minimum -- a typo can never turn the bot into a hammer.
- Backoff -- a
429/503response with aRetry-Afterheader is honored. - Retries + timeout -- transient failures (network errors, timeouts,
429/503) are retried a few times with a short backoff, and every request is bounded by a timeout, so a flaky server or connection never burns a whole poll cycle -- and never takes the bot down. - Identifiable
User-Agent-- includes a contact URL (overridable viaIRC_FEED_USER_AGENT). - Stagger + jitter -- feeds within a cycle are polled sequentially with a short delay, and each feed's next poll is jittered so the bot never hits many hosts on the exact same tick.
Avoiding duplicates
Each feed's seen item ids (plus its ETag and Last-Modified) are persisted to
a JSON checkpoint file (IRC_FEED_CHECKPOINT_PATH, default
feed-checkpoints.json), so duplicates are suppressed across restarts. On the
very first poll of a brand-new feed the current items are recorded as seen
without announcing, so the bot does not flood the channel with a feed's
entire backlog on startup. While the bot is disconnected from IRC, polling is
paused entirely so it never marks items seen that it could not announce.
Announcement shape
IRC_FEED_MESSAGE defaults to 📰 {feed}: {title} -- {link}, with {feed}
(the feed title), {title}, and {link} substituted. At most
IRC_FEED_MAX_ITEMS (default 3) items are announced per feed per cycle
(keeping the newest), so a large backlog never floods the channel.
Forgejo repository-activity announcements
Independent of the RSS/Atom feeds: the bot can announce every new event
(issues, pull requests, comments, pushes, branches, tags) from one or more
Forgejo repositories by polling the Forgejo REST API. Any number of independent
Forgejo servers ("instances") are supported: enable the feature with
IRC_FORGEJO_INSTANCES, a comma-separated list of instance names, and give each
name its own IRC_FORGEJO_<NAME>_* family — a base URL, an access token, and a
comma-separated list of "<owner/repo> <#channel>" pairs to watch. Each
instance runs its own poller with its own token and seen-ids store, so a token
never crosses server boundaries:
IRC_FORGEJO_INSTANCES=WORK,COMMCODE
IRC_FORGEJO_WORK_API_URL=https://forge.work.example
IRC_FORGEJO_WORK_TOKEN=work-token
IRC_FORGEJO_WORK_REPOS=owner/repo #work-dev
IRC_FORGEJO_COMMCODE_API_URL=https://codeberg.org
IRC_FORGEJO_COMMCODE_TOKEN=comm-token
IRC_FORGEJO_COMMCODE_REPOS=forgejo/forgejo #foss
The unsuffixed IRC_FORGEJO_* knobs (IRC_FORGEJO_INTERVAL_MIN,
IRC_FORGEJO_LIMIT, IRC_FORGEJO_MAX_ITEMS, IRC_FORGEJO_MESSAGE,
IRC_FORGEJO_RELEASE_NOTES_MESSAGE, IRC_FORGEJO_USER_AGENT) are shared
defaults every instance inherits; each can be overridden per instance with the
corresponding IRC_FORGEJO_<NAME>_* variable. Store paths are strictly
per-instance (default forgejo-activity-<name>.json, lowercased) and must be
unique, since two instances sharing one file would clobber each other's state.
The same repo may appear in several instances (different servers); within one
instance each repo is listed exactly once. The removed single-instance format
(IRC_FORGEJO_API_URL / IRC_FORGEJO_TOKEN / IRC_FORGEJO_REPOS /
IRC_FORGEJO_STORE_PATH) now fails fast with a pointer at the instance format.
Log lines and one-time warnings carry the instance name ([forgejo:WORK]) so
side-by-side instances stay attributable, and the {instance} placeholder is
available in both message templates.
Not every event kind is worth hearing about, so event kinds can be opted out per
instance or per repo with IRC_FORGEJO_IGNORE_OP_TYPES (a comma-separated list
of op_types; empty announces everything). Ignored events are still recorded as
seen, so they are never announced -- not even on later cycles. Every repo
inherits the instance-wide list unless it overrides it with an optional third
field in its REPOS entry (+-joined, since commas already separate repos):
# skip pushes and tag pushes across the whole instance
IRC_FORGEJO_IGNORE_OP_TYPES=commit_repo,push_tag
# ...but let a particular repo keep them, or ignore extras just for one repo
IRC_FORGEJO_WORK_REPOS="owner/repo #work-dev commit_repo+push_tag"
Unknown op_types in the ignore list are rejected at startup so a typo like
commit_repoo fails fast instead of silently never matching. The modeled
op_types are commit_repo (push), push_tag (tag push), create_branch,
delete_branch, delete_tag, publish_release, rename_repo, the issue/PR
lifecycle (create_issue, close_issue, reopen_issue, comment_issue,
create_pull_request, close_pull_request, reopen_pull_request,
comment_pull, merge_pull_request, approve_pull_request,
reject_pull_request).
It exists because Forgejo's web .rss route (/{owner}/{repo}.rss) does not
accept access tokens -- only session cookies -- so a headless bot must use the
API instead:
GET {api}/api/v1/repos/{owner}/{repo}/activities/feeds
Authorization: token <TOKEN> # scopes: read:repository + read:issue
The API returns structured activity objects without pre-rendered titles, so the
announced line is reconstructed from the event's op_type (calibrated against
Forgejo's own RSS wording, e.g. al3x commented on issue owner/repo#47).
Unknown op_types fall back to a generic title and log a one-time warning so
the map can be extended.
Release activities (publish_release) are announced with the release name and
point to its per-tag page, e.g. al3x released v2.4.0 at owner/repo with a
{link} of .../owner/repo/releases/tag/v2.4.0. Forgejo's activity event
carries only the release name — the detailed release notes live on the
release object alone.
Because of that, for each release that has notes the bot makes one follow-up API
call (GET /releases/tags/{tag}) and posts a second message via
IRC_FORGEJO_RELEASE_NOTES_MESSAGE (defaults to 📝 {notes}). Multi-line notes
are delivered as a single
IRCv3 draft/multiline batch
when the server advertises the capability — the whole notes render as one
message, with the server's max-bytes/max-lines limits honored (and a …
marker appended when notes had to be trimmed to fit). On servers without the
extension the notes fall back to one IRC line per note line. The template prefix
applies to the first line only in both cases. Set that variable to an empty
value to disable the notes message, in which case no follow-up call is made.
Only announced releases (not the whole backlog) trigger the extra call.
Politeness mirrors the feed poller: a hard 5-minute floor, per-repo scheduling
with stagger + jitter, Retry-After honored on 429/503, transient failures
retried with a short backoff, and polling pauses while IRC is offline. The token
is sent only as an Authorization header -- it never appears in URLs, logs, or
the on-disk seen-ids file. On the first poll of a repo the current events are
recorded as seen without announcing, so only events that happen afterwards
are announced. IRC_FORGEJO_MESSAGE defaults to {title} and supports
{title}, {link}, {repo}, {user}, and {instance} (plus {notes} in the
separate release-notes template).
HTTP notification webhook
The bot can expose a tiny HTTP endpoint so other services (or a cron job) can
push a one-off message into an IRC channel the bot is already in. The feature is
off unless IRC_WEBHOOK_TOKEN is set -- with no token, no server is
started, so the endpoint is never exposed by accident. When enabled it binds to
IRC_WEBHOOK_HOST:IRC_WEBHOOK_PORT (default 127.0.0.1:8080).
Send a POST with JSON {"message": "...", "channel": "#chan"} and a bearer
token:
curl -sS -X POST http://127.0.0.1:8080/v1/send \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"message":"deploy finished","channel":"#home"}'
Rules and status codes:
- 401 -- missing/malformed
Authorization: Bearerheader, or wrong token. - 400 -- malformed JSON; a missing/empty
message; amessagelonger thanIRC_WEBHOOK_MAX_MESSAGE_BYTES; amessagecontaining NUL or other control characters (TAB/LF/CR are allowed); or a missing/#-lesschannel. - 404 -- the
channelis syntactically valid but is not one the bot has joined (onlyIRC_GREETING_CHANNELplus every feed/repo channel is allowed). - 405 -- any method other than
POST. - 503 -- the bot is currently disconnected from IRC (the caller should retry later); the message is not queued.
- 200 -- accepted and handed to IRC:
{"status":"sent","channel":"#home"}.
Newlines in message are allowed and are delivered as an
IRCv3 draft/multiline batch on
servers that support it, otherwise as one line each.
Usage
Run the bot (it needs network, environment, and filesystem access):
cp .env.example .env # then edit .env
deno task start
For development with automatic reload on file changes:
deno task dev
Compile a standalone binary:
deno task compile
./dist/bot
Network resilience
The bot never crashes on a transient network failure -- neither during the initial connection nor after a successful connection drops (e.g. during an IRC server upgrade). Every failure is logged and retried after a fixed 10-second backoff, in an infinite loop, until the server is reachable again. This means the bot survives IRC server restarts, brief network outages, and connection drops without needing an external supervisor to restart it.
Development
Development docs — the task table, Git hooks, full src/ project structure,
module-layout conventions, and the Fallow codebase-analysis setup — live in
AGENTS.md.