The hutbot is a simple Slack bot that monitors messages in a channel and automatically replies in a thread if no one reacts or responds to the message within a configurable time period (by default 30 minutes). The bot reminds channel members that a message has gone unanswered. Scheduled reminders are cancelled when someone replies in the thread or reacts in a way that does not match the config criteria that caused that reminder to be scheduled, or when the original message is deleted. Users can adjust both the waiting time and the reminder message directly within the channel, including {{variable}} placeholders in the reminder text.
Run /hutbot (or just @hutbot on its own) with nothing after it to get the full command
list; /hutbot help does the same. /hutbot help variables prints the separate reference of
every {{variable}} — grouped by message, date/time, Opsgenie and calendar — plus the
condition operators. Any argument may be quoted with ", ' or backticks —
handy since Slack renders a backticked value as code as you type it.
A reply message can span several lines: a command arrives as a single line, so type \n
where the break belongs — /hutbot set message "First line.\nSecond line." — and \\n when a
literal backslash-n is what you want. Every other backslash is left alone. The web UI takes
real line breaks in its text box instead.
A reply message can @mention someone by email address — @d.grieser@example.com — which
is resolved to their Slack user when the message is set, alongside the existing @username
form. An address that maps to nobody is reported there and then rather than rendering wrongly
later.
Reply messages support built-in placeholders such as {{user}}, {{channel}}, and {{message_link}}. {{date}}, {{time}}, and {{datetime}} render the triggering message's time — or the time the rule ran, for cron/manual triggers and for test/run — using the config's date/time format, timezone, and locale, and take the same fmt/tz/lc arguments as the Opsgenie date/time variables below, plus the at argument of the calendar variables — {{date(at="+2w")}} is a fortnight after the instant the plain form reports. {{timestamp}} is the raw Slack timestamp of the same instant. If a channel config also has an Opsgenie schedule configured via /hutbot [config] set opsgenie-schedule <name>, Hutbot can resolve the current on-call person and expose:
{{opsgenie_schedule_name}}for the configured schedule{{opsgenie_current_user}}for a Slack@mention{{opsgenie_current_email}}for the Opsgenie recipient email{{opsgenie_current_name}}for the resolved Slack display name, or the Opsgenie email if no Slack match is found{{opsgenie_current_start_date}},{{opsgenie_current_start_time}}, and{{opsgenie_current_start_datetime}}{{opsgenie_current_end_date}},{{opsgenie_current_end_time}}, and{{opsgenie_current_end_datetime}}{{opsgenie_next_user}},{{opsgenie_next_email}}, and{{opsgenie_next_name}}{{opsgenie_next_start_date}},{{opsgenie_next_start_time}}, and{{opsgenie_next_start_datetime}}{{opsgenie_next_end_date}},{{opsgenie_next_end_time}}, and{{opsgenie_next_end_datetime}}
If a channel config has a calendar feed configured via /hutbot [config] set calendar <name|url>,
Hutbot reads the ICS feed and exposes one event — by default the one running when the rule
fires. Which event is a matter of two arguments, offset and at, described below:
-
{{calendar_name}}for the built-in calendar's title when the config uses one, else the feed's own name (X-WR-CALNAME), else its redacted URL —<unknown-calendar>when the config names a built-in this instance no longer offers -
{{calendar_summary}},{{calendar_location}},{{calendar_description}} -
{{calendar_organizer}}(display name) and{{calendar_organizer_email}} -
{{calendar_attendees}}(display names) and{{calendar_attendee_emails}}— lists, rendered comma-separated — plus{{calendar_attendee_count}} -
{{calendar_other_attendees}}/{{calendar_other_attendee_emails}}— the same lists without the organizer. A shared mailbox invites itself to the events it organizes, so on a rota entry organized by Notfallhotline these leave just the person actually on call -
{{calendar_organizer_user}},{{calendar_attendee_users}}and{{calendar_other_attendee_users}}— the same people mapped to Slack users, as<@U…>mentions. Addresses are matched against the Slack directory with the bot's usual user mapping; anyone without a Slack account is simply left out, so a mapped list can be shorter than the address list it came from -
{{calendar_uid}},{{calendar_status}} -
{{calendar_start_date}},{{calendar_start_time}}, and{{calendar_start_datetime}} -
{{calendar_end_date}},{{calendar_end_time}}, and{{calendar_end_datetime}}
A list variable renders comma-separated, and nth picks one entry out of it, counting from 1:
{{calendar_attendees(nth=2)}} is the second (n=2 is the short spelling). Asking for
an entry the list does not have renders empty rather than failing, so a message written for
two attendees still reads when there is only one:
/hutbot oncall set message "On call: {{calendar_other_attendees(nth=1)}} <{{calendar_other_attendee_emails(nth=1)}}> until {{calendar_end_time}}"offset counts events and at moves time. Either may be left out, and they compose:
{{calendar_summary}} # running now
{{calendar_summary(offset=next)}} # the next one — `prev` for the one before
{{calendar_summary(offset=+2)}} # the one after next; `-2` counts back
{{calendar_summary(at="2026-08-27 09:00")}} # what is on at 09:00 on the 27th
{{calendar_summary(at="+1d", offset=next)}} # the event after whatever runs this time tomorrow
{{calendar_summary(offset=same-day)}} # running now, else the next one *today* — nothing after midnight
In a gap between events the plain form renders <no-event> — it never silently reports the
upcoming one — while offset=prev and offset=next are still the events either side of that
moment. With an event running, prev and next are its two neighbours. An offset counts at most
20 events either way, and prev searches back 90 days (HUTBOT_CALENDAR_LOOKBACK_DAYS), so a
longer gap reports <no-event> too.
offset=same-day is the one value that counts nothing: it is the event running at that moment,
or the first one starting later the same calendar day (the day in the config's timezone), and
<no-event> once the day is out. That is the question a rota check asks — is this day covered
from here on? — and the one offset=next cannot answer, because it happily reports an entry
three days later, which is exactly the uncovered day it was asked about. Spelled same-day,
same_day, that-day, today or day.
# nobody has the hotline on the day two weeks out — checked at 10:00 for an 18:00 entry
/hutbot notfallhotline-check add condition calendar_other_attendee_emails(at=+2w, offset=same-day) emptyat accepts:
| Spelling | Means |
|---|---|
at="2026-08-27 09:00" |
that wall clock in the config's timezone (a T and seconds are both fine) |
at="2026-08-27T09:00:00Z", at="2026-08-27T09:00+09:00" |
an absolute instant, converted to the config's timezone |
at="2026-08-27" |
00:00 that day — exactly right for an all-day rota entry, and usually finds nothing on a calendar of timed meetings |
at="09:00", at=9 |
today at that hour, the spelling set work-hours takes |
at="+2h", at="-30m", at="+1d", at="+2w" |
a signed offset from the moment the rule fires |
+1d is this time tomorrow; +24h is 24 real hours — on the two days a year the clocks
change, those differ by an hour. A time without a Z or a +02:00 is read in the config's
timezone, never UTC, and tz only decides how the answer is printed, so an explicit offset is
how you name a moment in another zone. A moment in the past is fine. A relative at is measured
from when the rule runs, so a message reminder with a 30-minute delay measures +1d from the
reminder, not from the message. {{calendar_name}} takes neither argument.
{{date}}, {{time}} and {{datetime}} take at too — but not offset, having no events to
count. Their at is counted from the instant they already report, the triggering message's time
(or the run's, for cron/manual and test/run), which is the one place a relative at is not
measured from the run: {{date(at="+0m")}} is always the plain form, and the same 30-minute
reminder measures {{date(at="+1d")}} from the message it answers. An at naming a moment
outside the range a date can hold renders <unknown>. A condition takes it the same way, written
on the variable — add condition date(at="+2w") equals 25.08.2026 — and is judged against the very
value the same expression renders in a message.
Calendar date/time variables take the same fmt/tz/lc arguments as the Opsgenie ones. For an
all-day event the end is the inclusive last day, not the exclusive DTEND the ICS file
carries — a one-day event on the 19th reports the 19th, not the 20th. Cancelled events are skipped;
tentative ones are kept, and {{calendar_status}} says which is which. Recurring events
(RRULE, with EXDATE exclusions) are expanded, including their VTIMEZONE.
A button press and an escalation timeout both run another config. The config they run can read
what triggered it through the parent_* variables:
{{parent_config}}— the triggering config's name{{parent_message}}— the full text it posted{{parent_variables}}— what its own{{…}}came out as, in the order its message reads them{{parent_date}},{{parent_time}}and{{parent_datetime}}— when it posted, taking the samefmt/tz/lcarguments as every other date/time variable{{parent_timestamp}}— the raw Slack timestamp of the same instant{{parent_action}}and{{parent_target}}— how it was sent (reply,dm_user,group_dm,post_channel) and the target it was sent to, already rendered{{parent_recipients}}— who received it, as#channelor<@person>: the channel forreplyandpost-channel, the person fordm-user, every member forgroup-dm
{{parent_variables}} renders comma-separated like any list variable, and nth picks one entry
counting from 1. of names one instead of counting to it, which survives someone reordering the
parent's message:
{{parent_variables(nth=1)}} the first {{…}} of the parent's message
{{parent_variables(of="calendar_summary")}} whatever the parent rendered there
Two things are easy to get wrong. {{message}} still means the original Slack message, which
travels down the whole chain — {{parent_message}} is the text the config immediately before this
one posted, so the two are different things. And where one config runs another which runs a third,
the parent is always the one immediately before: the third sees the second, never the first.
Nothing triggered a message, cron or manual run, nor a test or run command, so there the
whole family renders <no-parent>. In a button's ack text the family describes the message the
button is attached to, which is how an ack can quote what it is acknowledging.
What this cannot tell you: who read the message — Slack reports delivery, not readership — and
which people a channel post reached, because {{parent_recipients}} names the channel rather than
its members. A group-dm does list its members, as they stood when the message was sent.
/hutbot escalate set message "No answer to {{parent_config}} from {{parent_datetime}} to {{parent_recipients}}: {{parent_message}}"
/hutbot escalate add condition parent_config equals reminderThe press_* variables describe the press itself, rather than the config behind it. They are
filled in for a button's ack text and for a config a button runs:
{{press_label}}— the label of the button that was pressed{{press_user}}— who pressed it, as a<@mention>, and{{press_user_name}}as their name{{press_date}},{{press_time}}and{{press_datetime}}— when it was pressed, taking the samefmt/tz/lcarguments as every other date/time variable{{press_timestamp}}— the raw timestamp of the same instant{{press_kind}}—userwhen a person pressed the button,timeoutwhen nobody did in time and the escalation auto-pressed the default button
A timeout press has a button and a time but nobody behind it, so {{press_user}} and
{{press_user_name}} render empty there — which is how one ack text can cover both cases:
/hutbot incident add button "I've got it" ack "{{press_user}} is on it, as of {{press_time}}."
/hutbot page-oncall add condition press_kind equals timeout # page only when nobody pressed in timeAn escalation that runs a config instead of auto-pressing a button is a timeout without a press,
so the whole family renders <no-press> there — as it does for a message, cron or manual
run and for test/run.
{{press_user}} is not the same as {{user}}: a run started by a button still reports the
original message's author as {{user}}, so the press is an addition to that context and never
a replacement for it.
Opsgenie date/time variables support fmt/format, tz/timezone, and lc/locale arguments, for example {{opsgenie_next_start_datetime(format='02.01.2006 15:04', timezone='Europe/Berlin', locale='de_DE')}}. The default date/time output for Opsgenie variables and /hutbot on-call can be configured with:
/hutbot [config] set datetime-format "<date>" "<time>" [<timezone> <locale>]
/hutbot [config] set date-format "<date>" "<time>" [<timezone> <locale>]
/hutbot [config] set datefmt "<date>" "<time>" [<timezone> <locale>]The <timezone> is also the timezone the config's work days and work hours are
counted in — /hutbot [config] set work-hours 9:00 17:00, /hutbot [config] clear work-hours to
react at any hour, and /hutbot [config] enable only-work-days for Mon–Fri only. Without a
timezone, all of them fall back to the server's local time.
Instance-wide defaults for configs that set neither come from the deployment
(time.timezone / time.locale in the Helm chart, HUTBOT_TIMEZONE /
HUTBOT_DEFAULT_DATETIME_LOCALE for helmfile):
time.timezoneis the container'sTZ, i.e. the server local timezone. The image itself is UTC, so without it every config that sets no timezone formats and counts work hours in UTC.time.localeis the fallback date/time locale. Onlydehas translations; every other locale renders English day/month names. The server's ownLANGis never used for this.
Neither has a default: without them the instance runs UTC with English names.
The .env / .env-dev files the deploy scripts source are the place to set
them, e.g. HUTBOT_TIMEZONE='Europe/Berlin' and
HUTBOT_DEFAULT_DATETIME_LOCALE='de_DE'.
show config prints what a config actually ends up with, e.g.
Date/time timezone Europe/Berlin (CEST, UTC+02:00, server local time) and
Date/time locale de_DE (instance default).
Opsgenie alert priority defaults to P4 and can be configured per channel config with:
/hutbot [config] set opsgenie-priority <P1|P2|P3|P4|P5>A config can read one ICS calendar feed, the same way it has one Opsgenie schedule — either a
built-in calendar the instance provides, or a published .ics link of your own:
/hutbot list calendars # the built-in calendars available here
/hutbot [config] set calendar <name> # one of them, by name
/hutbot [config] set calendar <url> # or a published .ics link
/hutbot [config] clear calendar
/hutbot [config] show calendar # the event before, the one now, and the nextThe event a config points at becomes the {{calendar_*}} template variables (see above), so a
message can name it — and a condition can gate a rule on it. offset and at pick a different
event or a different moment: {{calendar_summary(offset=next)}}, {{calendar_summary(at="+1d")}}.
Built-in calendars come from the instance's calendar bridge: it publishes which calendars it
serves, and Hutbot reads that list at startup and then hourly, so a calendar the bridge gains shows
up without a deployment. Each has a name (what you type), a title (what Hutbot shows — the name
the calendar itself carries) and a feed URL that only the deployment knows. A deployment can offer
further calendars on top, and override one the bridge serves, through
HUTBOT_BUILTIN_CALENDARS; both are described under
Built-in calendar feeds. An instance with neither has no built-in
calendars, and a config there needs a published .ics URL of its own.
Pointing a config at one stores the name, never the URL, so bot.json never holds a feed
token and rotating one takes effect without editing a single config. show config names the
calendar by its title (Calendar Platform on-call rota (built-in: rota)) and {{calendar_name}}
renders that same title. Two configs on the same built-in share one fetch.
A built-in name may hold lower-case letters, digits, ., - and _ — never : or / — which
is how set calendar <value> tells a name from a URL. If a built-in is renamed or removed while
a config still names it, that config fetches nothing, {{calendar_name}} renders
<unknown-calendar>, and show config says built-in: rota (not available on this instance). A
bridge that is briefly unreachable is not that case: the calendars from the last listing stay
available until a later refresh replaces them.
The URL is a secret. A published-calendar link needs no login, so possession of it is read
access to the calendar. Hutbot therefore only ever echoes a redacted form
(outlook.office365.com/…) in show config and in the setter's confirmation — and a
built-in calendar's URL is never printed at all, not even redacted, because it belongs to the
instance rather than to the channel that uses it.
The feed is cached for 5 minutes (HUTBOT_CALENDAR_TTL, in seconds), and offset=prev searches
back 90 days (HUTBOT_CALENDAR_LOOKBACK_DAYS) — widen that for a calendar whose events are
months apart. Both are chart values as well (calendar.ttlSeconds, calendar.lookbackDays), so a
deployment can set them without touching the image.
A feed that cannot be fetched keeps answering from its last copy for a day past that TTL
(HUTBOT_CALENDAR_STALE_GRACE, calendar.staleGraceSeconds; 0 switches it off), because a condition judged on a
day-old calendar is wrong far less often than one judged on no calendar — "on vacation" would
otherwise quietly become "not on vacation" for as long as the host is down. Past that the copy is
dropped. A feed that just failed is not asked again until its TTL is up, so a host that is down
costs three attempts per TTL rather than three per message.
A remote feed must be https://, and URLs pointing at any non-global address — including private,
link-local and the shared 100.64.0.0/10 range — are refused because the bot fetches them from
inside the cluster. The host name is resolved and checked too, at every redirect hop, so a public
name that answers with 10.0.0.1 is refused just the same. Loopback is exempt, over plain
http:// too, so a local file server works while developing:
/hutbot set calendar http://127.0.0.1:8073/calendar.ics.
An internal feed — an in-cluster ICS bridge, say — is refused by that same check, which cannot tell
it from a target somebody talked the bot into. Name its host in HUTBOT_CALENDAR_ALLOWED_HOSTS
(comma-separated, exact host names, https:// still required) to exempt it. This is an operator
setting, not something a channel can set, and it exempts one name rather than a range. The
NETWORKPOLICY_RULES entry for the host is still needed as well: the allow-list lets the bot try,
the egress rule lets the packet leave.
/hutbot rename config <name> <new-name>
/hutbot delete config <name>A config's name is its identity — there is no separate name field — so renaming one moves everything that points at it in the same step:
- the button targets (
add button "<label>" config <name>) and escalation targets (set escalation <minutes> config <name>) of every config in the channel, including the renamed one where it escalates to itself; - buttoned messages already posted and still waiting for a press, so a press or a timeout minutes later still runs the right config;
- reminders already queued for an unanswered message, which keep their deadline.
The reply says what else moved, e.g. "also updated 2 rules and 1 posted message". The
default config cannot be renamed, a name already in use is refused, and so is a name that
starts a command (set, delete, rename, …) — the same rules the web UI applies, where
renaming is the Rename button next to Delete rule.
/hutbot export config [<name>]
/hutbot import config [<name>] <json>export config prints one config as JSON, ready to paste into import config in another
channel (or on another instance). Only the fields that differ from the defaults are exported:
{
"format": "hutbot-config/1",
"name": "escalate",
"settings": {
"wait_time": 600,
"reply_message": "Anybody there?",
"trigger": "manual"
}
}import config creates the config, or replaces it entirely if one with that name already
exists. A name given in the command — import config <name> <json> or the <config> prefix —
wins over the name in the JSON; without either, the exported name is used. The JSON may be
pasted bare or inside a code block, and a plain { "wait_time": 600, … } settings object
without the envelope is accepted too.
Every import runs through the same validation as the web UI save and the setters, so an
import cannot store what a setter would have refused; a refused import changes nothing and
lists each offending field. Fields missing from settings take their defaults. Two fields
never travel: disabled_reason is the bot's own bookkeeping, and the calendar feed URL is a
bearer secret that must not be printed to the channel — the export says so when one is set,
and set calendar <url> restores it on the imported config (a calendar_builtin name is
exported normally).
Beyond the classic "reply if a message goes unanswered" behavior, each named config is a rule
with a trigger, an optional condition, and an action. The trigger defaults to message, so
existing configs keep working unchanged.
-
Triggers (
/hutbot [config] set trigger <trigger> [<cron expression>]). The cron trigger carries its expression, so a rule can never be left with a schedule that never fires.set trigger message— the classic behavior (matches channel messages by pattern/teams/hours).set trigger cron "0 9 * * 1-5"— fires on a 5-field cron schedule, in the config's date/time timezone (seeset datetime-format) or server local time without one.scheduleandscheduledare accepted as names for this trigger.set trigger manual— never fires on its own; used as the target of a button or a button timeout.
-
Conditions gate a rule on any
{{variable}}, and apply to every trigger. Each one is added separately, so several chain together:/hutbot [config] add condition <variable> <operator> ["value"] [0|1]— the trailing0|1is case sensitivity (default0, case-insensitive), exactly likeset pattern./hutbot [config] set condition-mode <all|any>— whether every condition must apply, or any one of them is enough. Defaults toall./hutbot [config] clear conditions— remove them all; the rule stops being gated.- Operators:
empty,not_empty,equals,not_equals,contains,not_contains,starts_with,not_starts_with,ends_with,not_ends_with,regex,not_regex. Common spellings are accepted too (is,=,!=,has,matches,not contains, …). - The left-hand side is any supported reply variable, so a rule can react to the message
(
{{message}}), the sender ({{team}}), the on-call state ({{opsgenie_current_user}}), or the calendar ({{calendar_summary}}). - List variables — the calendar's
attendees/attendee_emailsand theirother_*counterparts — match when any entry matches, and theirnot_forms when none does. Soequalsis a membership test andnot_equalsmeans "not among them", rather than the useless "some entry differs":# is this person on the event that is running right now? /hutbot oncall add condition calendar_attendee_emails equals nico@example.com # …and `at`/`offset` work here too, written on the variable: /hutbot oncall add condition calendar_summary(at=+1d) contains Wartung # `at` on the clock family as well — no `offset`, they have no events to count /hutbot oncall add condition date(at=+2w) equals 25.08.2026 # nobody from outside the company is on it /hutbot oncall add condition calendar_attendee_emails not_contains @external.com
- Quote a value that contains spaces if you also want the case flag:
add condition message contains "deploy to prod" 1. - A condition that cannot be judged (an unknown variable, an invalid regex) counts as not met, whatever its operator — so a broken config stays quiet instead of paging someone.
- Conditions are evaluated when the rule fires — for a
messagerule that is after the reminder delay, so a calendar or on-call condition is asked about the moment the reply would go out, not the moment the message arrived. - Pending work is judged against the conditions that were in place when it was committed to — when the message arrived for a reminder, and when the message was posted for a button or an escalation. Editing a rule never retroactively changes what an already-queued reminder or an already-posted buttoned message does, the same way a press is resolved against the buttons that were posted. Both snapshots survive a restart.
- A condition that reads only settled values — the message, its sender, their team, and the
{{date}}/{{time}}/{{datetime}}family,atand all, which count from the message's own timestamp — is checked immediately, and no reminder is queued at all when it already cannot pass. Conditions on{{calendar_*}},{{opsgenie_*}}or{{message_link}}can only be answered later, so those always wait. /hutbot [config] testprints each condition with ✓/✗ and whether the rule would run.
# nudge only while a "Composer" meeting is actually running /hutbot standup set calendar https://outlook.office365.com/owa/calendar/…/calendar.ics /hutbot standup add condition calendar_summary contains composer /hutbot standup add condition calendar_summary(offset=next) not_empty /hutbot standup add condition message not_empty /hutbot standup set condition-mode all
-
Actions (
/hutbot [config] set action <action> [<target>]) decide what the rule does, usingreply_message(with the usual{{variables}}) as the body. The recipient is part of the same command, so a config can never be left with an action that has nowhere to send.set action reply— post in the rule's channel. Takes no target.set action dm-user @<user>— DM a single user. The target may be a mention, an email address, or a{{variable}}, soset action dm-user {{calendar_other_attendee_users(nth=1)}}DMs whoever the calendar says is on call right now.set action group-dm @<usergroup>— open one group DM (mpim) with all members of a Slack user group and post once (Slack caps a group DM at 8 members). The target may instead name the people directly — mentions or addresses, including from a variable:set action group-dm {{calendar_attendee_users}}. A bare handle like@sreis always taken as a user group, so nothing changes for existing configs.- A target built from variables is resolved when the rule runs, and the rule simply does not send when it resolves to nobody.
set action post-channel #<channel>— post to another channel. Pick the channel from Slack's autocomplete so it arrives as a link, or pass itsC…id.
-
Buttons attach interactive buttons to the message a rule sends (including the classic unanswered-message reply). Each button is added incrementally with a typed action (the leading
add, likesetelsewhere, is optional):/hutbot [config] add button "<label>" config <config>[,<config>…]— run another config (amanualrule), or several: comma-separate them and one press runs each in turn, in the order written. A name cannot contain a comma, so the list needs no quoting; blanks are dropped and a name written twice runs once./hutbot [config] add button "<label>" ack [text]— mark the message handled: stop any escalation, remove the buttons, and post[text]in the thread when given. Like a reply message, the text may use{{variables}}and@mentions, both resolved against the original message when pressed, plus the{{press_*}}variables of the press itself.[text]is not a private confirmation for whoever pressed: it is posted as a thread reply under the buttoned message, so it lands in whatever conversation that message went to — this channel, another channel, or a DM — and everyone who can see that conversation can read it. Whoever pressed is named on the message itself, where the buttons were./hutbot [config] add button "<label>" delay <minutes>— push the escalation out by N minutes and leave the buttons in place (the only button that does not consume the message). It needs an escalation to postpone, so set one first;clear escalationwarns if it strands such a button./hutbot [config] clear buttons/hutbot [config] set escalation <minutes> button "<label>"— if nobody presses within<minutes>, auto-press that button (exactly as a human click would)./hutbot [config] set escalation <minutes> config <config>[,<config>…]— …or run that config instead, or several of them, comma-separated like a button's./hutbot [config] clear escalation— never escalate; the buttons stay open until pressed. The timeout and its target are one setting, so a timer can never exist with nothing to fire. Pending escalations survive restarts.- A button/timeout that runs a config passes the original message context to it, so the target's
templates and any OpsGenie alert reference the original message. The config it runs can also read
what triggered it — its name, its text, when it posted and who it reached — through the
{{parent_*}}variables. - Where a button or an escalation names several configs, each gets that same context and each is judged on its own conditions: one declining does not stop the next, and the note left on the message says per config which ran and which was skipped. They all sit at the same depth in the chain, so a list is a fan-out and not a longer chain.
- A chain of configs is cut off after 10 hops, so a config that escalates to itself (directly or
around a loop) stops instead of posting once per timeout forever. The count travels with the
pending message, so a restart does not reset it.
add condition parent_config emptyis the way to say "only run when nothing triggered me". - Once a message is handled, its buttons are removed and replaced by a one-line note of what happened: Dave Grieser: [I've got it] or Dave Grieser: [Page] ▶︎ alarm for a press, and ⌛︎ 1m or ⌛︎ 5m ▶︎ alarm when the escalation acted instead. A button that runs several configs names each of them — Dave Grieser: [Page] ▶︎ alarm, ticket (skipped) — so the note says which of the list actually went out.
- An escalation Slack refuses to take is tried again — three times, a minute apart — and the buttons stay on the message until one of them gets through or the attempts run out. Where it runs several configs, only the ones that did not get through are tried again, so the ones that went out do not go out once per attempt. A target that fails outright rather than being refused counts as one of those, and does not stop the configs named after it. A restart in the middle of that restores the escalation rather than losing it, so a Slack outage can duplicate an escalation but not swallow one. A reminder whose send fails is retried the same way. A config that declines — a condition says no — is not retried: it would decide the same thing a minute later.
-
"Need help?" example — a question that defaults to "Yes" if ignored:
/hutbot helpcheck set message Need help? /hutbot helpcheck add button "Yes" ack "Here's the help doc: …" /hutbot helpcheck add button "No" ack /hutbot helpcheck set escalation 5 button "Yes"
Pressing Yes posts the help message; No dismisses it; if nobody clicks within 5 minutes, Hutbot auto-presses Yes and posts the help message.
-
OpsGenie is just a config property: any config that runs and has OpsGenie enabled sends an alert (with
set opsgenie-message "<template>"to customise the alert text; default is the original message). So to button-gate an alert, put OpsGenie on a separatemanualconfig and run it from a button or the escalation — don't put OpsGenie on the question config itself (or it would alert as soon as the question is posted). Example:# question — no OpsGenie /hutbot incident set message Incident — on it? /hutbot incident add button "I've got it" ack /hutbot incident set escalation 5 config page-oncall # page if nobody acknowledges # alert config — OpsGenie on, runs only when escalated (or via a Yes-style button) /hutbot page-oncall set trigger manual /hutbot page-oncall enable opsgenie /hutbot page-oncall set opsgenie-schedule "Team Primary" /hutbot page-oncall set opsgenie-message "Unanswered incident in {{channel}}: {{message}}"
I've got it cancels the escalation (no page); otherwise after 5 minutes
page-oncallruns and pages on-call, referencing the original message. Adelaybutton pushes the 5-minute timer out.One escalation can do more than one thing:
set escalation 5 config page-oncall,notify-leadpages on-call and posts to the lead, each on its own conditions and each with the original message behind it.
Use /hutbot [config] run to fire a configuration's action immediately (handy for testing).
/hutbot [config] test renders one configuration without sending anything, and reports, in this
order:
- the message, rendered with the values it has right now;
- what the run would do — where it lands, with the target resolved to the people it actually
names (a rota rule usually targets
{{calendar_other_attendee_users(nth=1)}}, and this is where you find out who that is), when the rule fires next, what its buttons say, what it would alert, and whether it is disabled or missing a target; - the gates and the conditions — for a
messagerule, the checks the message itself has to pass (teams, work days and hours, the pattern, judged against the text you passed), then each condition with ✓/✗, and a verdict saying whether the rule would run and what stops it first; - the variables the configuration reads, grouped by the field that reads them — the message, the target, the alert text, each button text, and the conditions;
- every calendar event it resolved, one per moment the templates and conditions name, with the attendees, organizer, body and UID behind the values;
- the whole variable namespace, plus every calendar variable at
offset=nextandoffset=prev— the reference half of the command.
A preview resolves more than a run does: it reads the button texts (which a run only renders once somebody presses) and the two neighbouring events. It also reports what a run would not manage: recipients Slack's 8-member group-DM limit leaves out, a button or escalation pointing at a configuration that does not exist, an escalation on a rule that posts no buttons (which never arms a timer), and an empty OpsGenie body on a rule with no triggering message. Long reports are split across several Slack messages.
@hutbot [config] test <message> does the same with <message> as {{message}}.
Configurations outlive channel membership, so removing the bot from a channel would otherwise leave
cron rules firing (and pending reminders/escalations coming due) for a channel it can no longer
post in. When Hutbot is removed from a channel (kicked, or /removed):
- every enabled configuration of that channel is disabled and marked as disabled because Hutbot was
removed (
disabled_reason), which/hutbot show configand the web UI show; - all pending reminders scheduled for that channel are cancelled;
- all pending button escalations are dropped — both those posted in that channel and those posted elsewhere by one of its rules (so nothing escalates into a now-disabled configuration).
Configurations are not re-enabled automatically. When Hutbot is added back it posts a message
listing the configurations it disabled, which can then be re-enabled with /hutbot [config] enable
(or the web UI). Configurations that a user had disabled by hand are left alone and are not part of
that message.
Hutbot can serve a small web UI from the same bot process (no separate service) that lets a logged-in user view and edit the Hutbot rule configuration for the Slack channels they belong to. It shows configuration only — there is no message log or analytics. It is disabled by default.
The calendar section offers the instance's built-in calendars as a dropdown (hidden when there are none) beside the feed-URL field. The two are mutually exclusive: picking a built-in clears the URL, and a save carrying both is rejected. A stored built-in this instance no longer offers stays selected, labelled not available on this instance, so saving cannot silently drop it.
The UI is intended to run behind a Keycloak-fronting reverse proxy (e.g. oauth2-proxy). The proxy authenticates the user and passes their email in a trusted HTTP header. Hutbot resolves that email to a Slack user (reusing the same employee-list / Slack mapping the bot already uses), determines their team, and lists the channels that (a) have a Hutbot config and (b) the user is a member of. Editing is gated by channel membership.
Security model: Hutbot trusts the configured header. You must ensure the bot's HTTP port is only reachable through the authenticating proxy (in Kubernetes the Helm chart's NetworkPolicy plus a ClusterIP Service + Ingress do this). Anyone able to reach the port directly could spoof the header.
export HUTBOT_UI_ENABLED=1
# optional overrides:
export HUTBOT_UI_PORT=8080 # default 8080
export HUTBOT_UI_HOST=0.0.0.0 # default 0.0.0.0
export HUTBOT_UI_USER_HEADER=X-Forwarded-Email # default
python -m hutbotThen test with a manual header:
curl -H "X-Forwarded-Email: you@yourcompany.com" http://localhost:8080/api/me
curl -H "X-Forwarded-Email: you@yourcompany.com" http://localhost:8080/api/channelsOpen http://localhost:8080/ in a browser (send the header via a browser extension or your local proxy
during development). Without a valid, resolvable header the API returns 403.
Set HUTBOT_UI_USER_HEADER to match the header your proxy sends:
- oauth2-proxy →
X-Forwarded-Email(default) - Keycloak gatekeeper (louketo) →
X-Auth-Email - custom nginx
auth_request→ whatever header you configure
GET /healthz— unauthenticated, for Kubernetes probes.GET /api/me,GET /api/meta,GET /api/channels— require a resolvable user./api/metacarries the option lists the editor needs, including the built-in calendars as name and title only — their feed URLs never leave the bot.GET/PUT/POST/DELETE/api/channels/{id}/configs[/{name}]— require a resolvable user, and channel routes additionally require membership of the channel.
Enable the UI through the chart with the following values.yaml keys under ui::
ui:
enabled: true
port: 8080
userHeader: "X-Forwarded-Email"
service:
type: ClusterIP
ingress:
enabled: true
className: "nginx"
host: "hutbot.internal.example.com"
annotations: {} # put your Keycloak/oauth2-proxy auth annotations here
tls: []When ui.enabled is true the chart renders a Service, exposes the container port, adds /healthz
readiness/liveness probes, and opens the NetworkPolicy ingress for that port; the Ingress is rendered
when ui.ingress.enabled is also true. The Keycloak integration itself (oauth2-proxy / annotations)
is the operator's responsibility — Hutbot only consumes the resulting header.
Security: the UI trusts the proxy-set email header, so any pod able to reach the UI port can spoof it and edit configs. Restrict the NetworkPolicy ingress to your auth proxy / ingress controller with
ui.allowedIngress(a list of NetworkPolicy peers rendered into the rule'sfrom:). Leaving it empty allows all sources (back-compat) and is not recommended:ui: allowedIngress: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginx
-
Create a New Slack App
- Go to Slack API: Applications and click "Create New App".
- Choose "From scratch" and give your app a name and select your workspace.
-
Create an App-Level Token
- Go back to your app's settings.
- Navigate to "Basic Information".
- Scroll down to "App-Level Tokens".
- Click "Generate Token and Scopes".
- Give it a name (e.g.,
default) and add the scopeconnections:write. - Click "Generate" and copy the token.
-
Enable Socket Mode
- Go to "Socket Mode".
- Click on "Enable Socket Mode".
-
Add Required Permissions
- Navigate to "OAuth & Permissions" on the left sidebar.
- Under "Scopes", add the following bot token scopes:
channels:historychannels:readchat:writereactions:readim:historyim:readim:writempim:historympim:readmpim:writegroups:historygroups:readusergroups:readteam:readusers:readusers:read.emailcommands
users:readalso coversbots.info, which is what resolves a message from an app that posts without a bot user: such a message carries only aBxxxxbot id, whichusers.infoanswersuser_not_foundfor, and the app's name comes frombots.infoinstead.users:read.emailis what putsprofile.emailin theusers.listresponse, and without it the bot has no address for anyone: the web UI rejects every request as unauthenticated, and mapping an Opsgenie recipient or a calendar attendee to a Slack user falls back to guessing from the address's local part. Adding a scope to an installed app requires reinstalling it to the workspace before the new scope takes effect. -
Enable Event Subscriptions
- Go to "Event Subscriptions".
- Turn on "Enable Events".
- Under "Subscribe to bot events", add:
message.channelsreaction_addedmessage.groupsmessage.immessage.mpimmember_joined_channelmember_left_channel
-
Enable Interactivity (required for buttons)
- Go to "Interactivity & Shortcuts".
- Turn on "Interactivity".
- Because the app uses Socket Mode, no Request URL is needed — interactive
button presses (
block_actions) are delivered over the same socket.
-
Add Slash Command
- Go to "Slash Commands".
- Click "Create New Command".
- Set the command to
/hutbot. - Set the short description to
Configure @Hutbot. - Click "Save".
-
Install the App
- Go to "Install App".
- Click "Install App to Workspace" and authorize the app.
- Copy the Bot User OAuth Token; you'll need it later.
-
Run the App
export SLACK_BOT_TOKEN='xoxb-your-bot-token'
export SLACK_APP_TOKEN='xapp-your-app-level-token'
pip install -r requirements.txt
python -m hutbot
- See Hutbot Slack App
- See Hutbot_DEV Slack App
- Invite Bot
/invite @Hutbot
A GitHub Actions workflow automatically builds and publishes the Docker image to GitHub Container
Registry on pushes to main and on tags matching v*.*.*. Cutting a release is just pushing a tag:
git tag v1.1.0
git push origin v1.1.0The published tags are:
| Trigger | Image tags | Deployable |
|---|---|---|
Tag push v1.1.0 |
v1.1.0, 1.1.0 |
yes |
Push to main |
main, latest |
no |
docker pull ghcr.io/mittwald/hutbot:v1.1.0Note:
latestandmainare floating convenience tags. Deployments must pin an exact release tag — the Helm chart refuses to render ifimage.tagis empty, so a version has to be chosen explicitly.
This repository includes a Helm chart under charts/hutbot and a Helmfile configuration at helmfile.yaml.gotmpl.
Credentials are never Helm values. The chart requires existingSecret and reads a Kubernetes
Secret it does not manage, synced from Vault out of band before deploying — so no token ends up in
a values file, in the release metadata Helm keeps in the cluster, or in a pipeline variable. .env
holds only the non-secret deploy settings and is optional:
# Slash command this instance listens on; must match the Slack app. Default: /hutbot
export HUTBOT_SLASH_COMMAND='/hutbot'
# Name the bot uses for itself in help/news messages. Default: Hutbot
export HUTBOT_BOT_NAME='Hutbot'
# Timezone and date/time locale of the instance. Empty means UTC and English names.
export HUTBOT_TIMEZONE='Europe/Berlin'
export HUTBOT_DEFAULT_DATETIME_LOCALE='de_DE'
# Egress rules for INTERNAL destinations, as a space-separated list of <port>:<cidr[,cidr...]>
# entries. DNS and every public destination are already allowed by the chart defaults, so a
# public calendar feed or Slack needs nothing here; an internal host does, on the port it
# listens on, or the connection is dropped without a trace. Set NETWORKPOLICY_PUBLIC_EGRESS=false
# to close the public rule, NETWORKPOLICY_ALLOW_DNS=false to close DNS, NETWORKPOLICY_ENABLED=false
# to drop the policy entirely.
export NETWORKPOLICY_RULES='443:192.168.0.15/32 80:10.0.0.0/24,10.0.1.0/24'
# To define host aliases for the pod (/etc/hosts entries), you can set a comma-separated list of <hostname>=<ip> entries:
export HOST_ALIASES='lb.mittwald.it=192.168.0.15'
# Calendar feed hosts that resolve to an internal address and are meant to be fetched anyway.
# Without an entry the bot refuses the fetch and logs the address it resolved to. Needs the
# matching NETWORKPOLICY_RULES entry (and HOST_ALIASES, if cluster DNS does not serve the zone).
export HUTBOT_CALENDAR_ALLOWED_HOSTS='outlook-bridge.prod.example.systems'
# Calendar tunables; unset means the bot's own defaults, 300 seconds, 90 days and a day.
export HUTBOT_CALENDAR_TTL=300
export HUTBOT_CALENDAR_LOOKBACK_DAYS=90
# How long an unreachable feed's last copy is still used past the TTL; `0` stops that.
export HUTBOT_CALENDAR_STALE_GRACE=86400
# Minutes between reads of the calendar bridge's listing; unset means 60, `0` reads it once at
# startup. The listing URL itself carries a token and lives in the Secret, not here.
export HUTBOT_CALENDAR_BRIDGE_REFRESH=60To query the employee list locally from your terminal, you can use the standalone helper script:
python query_employees.py john
python query_employees.py --team Support
python query_employees.py john.slack --cache-only
python query_employees.py john --jsonThe helper reads the same employee cache and employees-fallback.json as the bot, so a fallback
record can be checked locally before it is copied onto the state volume.
Source of truth is Vault at https://vault.m3.services: secrets-coabkube/production/hutbot for
production, .../hutbot-dev for the dev instance. Every field becomes one key of the Kubernetes
Secret named after the release (mw-internal/hutbot, mw-internal/hutbot-dev). Ordinary fields
reach the bot through individual environment references; HUTBOT_BUILTIN_CALENDARS is mounted
only as a file so large lists do not hit the process environment's size limit.
| Field | Required | Purpose |
|---|---|---|
SLACK_APP_TOKEN |
yes | Socket Mode connection (xapp-…) |
SLACK_BOT_TOKEN |
yes | Slack Web API (xoxb-…) |
OPSGENIE_TOKEN |
no | Opsgenie alerts and heartbeat — an integration API key (a team's API integration) |
OPSGENIE_API_TOKEN |
no | Opsgenie on-call lookups — an account API key (Settings → API key management) with read access; falls back to OPSGENIE_TOKEN |
OPSGENIE_HEARTBEAT_NAME |
no | Heartbeat to ping; empty on dev so it cannot ping production's |
EMPLOYEE_LIST_USERNAME |
no | Employee-list credentials for team lookups |
EMPLOYEE_LIST_PASSWORD |
no | " |
EMPLOYEE_LIST_MAPPINGS |
no | user=alias overrides for the mapping; user=- silences the "cannot be mapped" warning for accounts with no employee record |
HUTBOT_CALENDAR_BRIDGE_URL |
no | The calendar bridge's listing endpoint, token included (see below) |
HUTBOT_BUILTIN_CALENDARS |
no | Calendars offered on top of the bridge's, and overrides of them (see below) |
Opsgenie's two key types are not interchangeable, which is why alerts and on-call lookups have a
field each. Creating an alert (POST /v2/alerts) and pinging a heartbeat go through an
integration API key, from a team's API integration; reading a schedule or its timeline
(GET /v2/schedules/… — what {{opsgenie_current_user}}, on-call and list opsgenie-schedules
resolve) is only served to an account API key from Settings → API key management, and that key
in turn creates alerts only with the Create and Update access right. Each key is rejected on the
other's endpoints, so a single field could serve only one of the two. OPSGENIE_API_TOKEN falls
back to OPSGENIE_TOKEN when it is unset: a secret written before the split keeps working, with
whatever half its one key is authorized for.
export VAULT_ADDR=https://vault.m3.services
vault login -method=oidc # Keycloak SSO in the browser
kubectl config use-context coabkube-prod
make sync-secret # production Secret `hutbot`
make sync-secret-dev # dev Secret `hutbot-dev`
make sync-secret ARGS='--dry-run' # key names and byte lengths only
make sync-secret ARGS='--restart' # sync, then roll the deploymentFilling an empty Vault path the first time is scripts/seed-vault.sh, which reads the fields
out of the env file that used to carry them:
make seed-vault-dev ARGS='--dry-run' # field names and byte lengths from .env-dev
make seed-vault-dev # write them to .../hutbot-dev
make seed-vault # and .../hutbot from .env
make seed-vault ARGS='--calendars ./calendars.json --sync'It picks only the secret keys it knows out of the file, drops the ones it does not set (an empty
OPSGENIE_HEARTBEAT_NAME stays unset rather than becoming ""), requires both Slack tokens,
validates a --calendars file the way the sync script does, and hands Vault a @file so no value
reaches a command line. It refuses a path that already has fields unless --force, because a
vault kv put replaces the whole version: seeding is a one-shot, and everything after it is a
vault kv patch.
scripts/sync-secret.sh refuses to run against any kube-context but coabkube-prod, rejects keys
that are not usable variable names, requires both Slack tokens, ignores Vault fields hutbot does not
read, validates the built-in calendar list and prints its feed hosts, and refuses a sync that would
remove a key the live Secret already has (--allow-drop, or --drop KEY, to mean it). Values move
through files, never through a command line.
vault kv putreplaces every field. Usevault kv patch <path> KEY=…to change one.secrets-coabkube/productionis a KV v1 mount today, where there is nokv patchand no version history to roll back to — so a careless put is unrecoverable and the Secret already in the cluster is the only remaining copy.scripts/edit-calendars.shhandles that: on v1 it merges the sibling fields back in explicitly, andsync-secret.shrefuses to write a Secret that would lose a key. On a v2 mount both usekv patch, andvault kv rollback -version=N <path>undoes a bad put.
Because the Secret is not part of the release, changing it does not restart the bot — the bot reads
its environment at startup, so pass --restart when a change has to take effect now.
Without Vault access, the same Secret can be built from .env:
set -a; . ./.env; set +a
./scripts/sync-secret.sh --local --dry-run
./scripts/sync-secret.sh --local --restartOnly the nine keys above are read — NETWORKPOLICY_RULES and the other deploy settings stay out of
the Secret — and a key missing from the environment is carried over from the Secret already in the
cluster, so updating one value never needs the others at hand.
The bot's state — bot.json (the per-channel configuration), the scheduled-reply and button
caches, the employee cache and its hand-placed fallback file — lives on the PersistentVolumeClaim,
which does not follow the release to a new cluster. The chart's stateImport seeds a fresh state
volume exactly once: an init container copies every key of an out-of-band Secret onto the volume
as a file, and never overwrites a file that already exists there, so a flag left on cannot
clobber live state and re-running the import is harmless.
scripts/import-state.sh handles both ends of the move. It confirms the current kube-context
instead of pinning one (an export runs against the old cluster, an import against the new), probes
the app directory as well as the state volume (versions up to v1.0.x kept scheduled_replies.json
next to the code), and validates every file as JSON before it becomes part of the Secret. Like the
credential Secret, the state files never pass through Helm values or the release metadata.
# 1. old cluster: read the state files out of the running instance (to ./state-export),
# then stop it — the target runs with the same Slack tokens, so a source left running
# would answer alongside it and re-send restored reminders. --stop scales the source
# deployment to zero right after the export; its release and volume stay put.
make export-state ARGS='--stop' # = ./scripts/import-state.sh --env prod --export --stop
# 2. new cluster: credentials first, then the state-import Secret
make sync-secret
make import-state # = ./scripts/import-state.sh --env prod --import
# 3. deploy once with the import switched on, and check the init container's log
STATE_IMPORT_ENABLED=true ./deploy-prod.sh v1.1.0
kubectl -n mw-internal logs deploy/hutbot -c state-import
# 4. deploy again with the flag off, then delete the Secret
./deploy-prod.sh v1.1.0
./scripts/import-state.sh --env prod --cleanupOrder matters at the end: the import volume is deliberately not optional — with the flag on,
a missing or misnamed Secret holds the pod at init rather than letting the bot start with an empty
state volume (and the deploy scripts refuse up front when STATE_IMPORT_ENABLED=true and the
Secret is absent, because strategy: Recreate takes the old pod down first). The init container is
fail-closed the same way: if the import ends without a bot.json on the volume — an empty or
mis-built Secret on a fresh volume — it exits nonzero and holds the pod, because the bot itself
treats a missing configuration as empty defaults and would happily start with nothing. So deploy
with the flag off before --cleanup; the script refuses to delete a Secret the deployment still
mounts. STATE_IMPORT_SECRET overrides the Secret name (default <release>-state-import) and is
read by the deploy scripts, the chart, and import-state.sh alike, so all three name the same
Secret.
The --export step only exists to get the files out of a running instance. When they are already
at hand — copied off the old volume, restored from a backup, or kept locally — the import starts
at the directory:
-
Put the files into one directory.
bot.jsonis required; the rest is optional and taken when present. Only these five names are picked up — anything else in the directory is ignored:File What it carries bot.jsonthe per-channel configuration (required) scheduled_replies.jsonreminders that were still waiting when the old instance stopped button_states.jsonbutton messages still waiting for a press or an escalation employees.jsonthe employee cache, warm before the first fetch employees-fallback.jsonthe hand-placed records for people the employee API does not return Every file must be valid JSON — the import refuses a truncated or hand-mangled file up front, rather than letting the bot start and fail to parse its own state. An old flat-format
bot.jsonis fine: the bot migrates it at startup. -
Make sure no other instance is still running with the same Slack tokens — an old deployment in another cluster included (
kubectl scale deploy/hutbot --replicas=0there). Two bots on one Slack app both answer, and both send the reminders restored from the copied caches. -
Point the kube-context at the target cluster and make sure the credential Secret is there (
make sync-secret, or./scripts/sync-secret.sh --localfrom.env). -
Build the state-import Secret from the directory:
./scripts/import-state.sh --env prod --import --dir ./my-state # or, when the directory is ./state-export: make import-state -
Deploy once with the import switched on, and check what the init container did:
STATE_IMPORT_ENABLED=true ./deploy-prod.sh v1.1.0 kubectl -n mw-internal logs deploy/hutbot -c state-import # expected: "state-import: imported bot.json" (and one line per further file); # "already on the state volume; keeping it" means the volume had the file and kept it
The bot's own log should then say
Configuration loaded from disk. -
Deploy again with the flag off, then delete the Secret:
./deploy-prod.sh v1.1.0 ./scripts/import-state.sh --env prod --cleanup
The built-in calendars come from the calendar bridge. HUTBOT_CALENDAR_BRIDGE_URL is its
listing endpoint — https://<host>/calendars/?token=…, the token included, which is why it is a
Secret field — and the listing it answers with is a JSON document naming the calendars the bridge
serves:
{"feeds": ["notfallhotline", "cloudhosting", "mkubed", "infrastruktur"]}Each of those hangs off the listing path as <name>.ics with the same token, so the bot needs
nothing but that one URL: it reads the listing at startup and then every
HUTBOT_CALENDAR_BRIDGE_REFRESH minutes (default 60, 0 reads it once at startup and never again;
calendar.bridgeRefreshMinutes is the chart value), and a calendar the bridge gains shows up within
that long rather than at the next restart. Each calendar's title is what its own ICS calls
itself (X-WR-CALNAME), read once per name and kept for the life of the process; a calendar whose
title cannot be read is shown by its name. One log line per change names the roster:
Calendar bridge serves: cloudhosting, infrastruktur, mkubed, notfallhotline.
Startup waits for that first listing — at most 45 seconds, then it comes up without it and says so — before restoring the reminders and button escalations it persisted: one that came due during the restart runs the moment it is restored, and it has to see the same calendars it saw before. Only the listing is waited for; the titles behind it are read afterwards, so a calendar is usable (under its name) the moment it is listed and an unreachable title endpoint delays nothing.
The URLs never appear in a log, a reply or bot.json — a config stores the name, so rotating the
bridge token takes effect without touching a single config. A bridge that cannot be read
(unreachable, 503, a document that is not a listing) leaves the calendars from the last successful
listing in place and says so; a listing that genuinely names nothing does clear them. An instance
with no bridge configured simply has no bridge-served calendars.
HUTBOT_BUILTIN_CALENDARS is the registry on top of that: a JSON array of
{"name", "title", "url"} objects for calendars the bridge does not serve — one behind a different
bridge, say, or a public feed — and for overriding one it does, since a name in both belongs to the
registry entry (which is then never fetched for a title either). Each URL grants read access to its
calendar, which is why the list lives in the Secret and not in a ConfigMap. The chart projects it as
a file (/etc/hutbot/builtin-calendars.json, mode 0440) and points
HUTBOT_BUILTIN_CALENDARS_FILE at it; builtinCalendars.mountFile=false disables the registry in
the chart. Source checkouts can still use HUTBOT_BUILTIN_CALENDARS directly when no file is
configured.
Create or rotate a registry entry:
make calendars # $EDITOR on the list from Vault, validated, written back
make calendars ARGS='--sync' # … and sync the Secret + restart in one go
make calendars ARGS='--show' # print the current list (it contains the feed tokens)On a KV v2 mount that write is a kv patch of the one field. On the v1 mount in use today there is
no patch, so the script merges the other fields back in from the copy it read when the edit started
— which is not atomic: nobody else should be editing that path at the same time.
By hand, the shape being the part worth remembering:
cat > calendars.json <<'JSON'
[
{ "name": "rota", "title": "Platform on-call rota",
"url": "https://outlook.office365.com/owa/calendar/…/calendar.ics" }
]
JSON
# KV v2 only — it touches just this field. The v1 mount in use today has no `patch`, so there
# `make calendars` is the way: a bare `put` would drop every other field.
vault kv patch secrets-coabkube/production/hutbot HUTBOT_BUILTIN_CALENDARS=@calendars.json
shred -u calendars.json
make sync-secret ARGS='--restart'@file keeps the JSON off the command line and byte-exact — and it is patch, not put, so the
Slack and Opsgenie fields beside it survive. A name may hold lower-case letters, digits, ., -
and _; an entry with a bad name, a blank title or a non-https URL is skipped at startup with a
warning naming it, and the rest of the list still loads.
Read a value back:
vault kv get -field=HUTBOT_BUILTIN_CALENDARS secrets-coabkube/production/hutbot | jq .
# what the cluster has:
kubectl -n mw-internal get secret hutbot -o jsonpath='{.data.HUTBOT_BUILTIN_CALENDARS}' | base64 -d | jq .
# what the pod sees, without the tokens:
kubectl -n mw-internal exec deploy/hutbot -- cat /etc/hutbot/builtin-calendars.json | jq -r '.[].name'The first two print the feed tokens.
The registry is read at startup, so a change to it needs make sync-secret ARGS='--restart' (or
kubectl -n mw-internal rollout restart deploy/hutbot). The Deployment uses strategy: Recreate —
hutbot is a singleton on a ReadWriteOnce volume, so a rolling restart would briefly run two bots
against one bot.json — which means every restart has a few seconds of gap.
The bridge's own listing is not read at startup only, so a calendar it gains needs no restart —
but a change to HUTBOT_CALENDAR_BRIDGE_URL itself does, since the bot reads its environment once.
Egress: a public feed host (outlook.office365.com and friends) is covered by the chart's
default public-egress rule — everything outside the private, shared and link-local ranges, on any
port — so it needs no entry anywhere. An internal host needs a HOST_ALIASES entry, because
cluster DNS does not serve that zone, a NETWORKPOLICY_RULES entry on its port, and its name in
HUTBOT_CALENDAR_ALLOWED_HOSTS, or the bot refuses the internal address before it dials. With only
some of the three the fetch fails — with a resolution error, a refusal in the log, or no trace at
all. sync-secret.sh prints the feed hosts so they can be checked against the rules. The calendar
bridge is such a host: mittwald-outlook-bridge.prod.mittwald.systems needs all three, and the
listing goes down the same guarded path as any feed — https only, every redirect hop revalidated.
make deploy-dev # newest release tag, release hutbot-dev
make deploy-dev ARGS='diff' # preview first
make deploy-prod # newest release tag, release hutbot
make deploy-dev IMAGE_TAG=v1.1.0 # or pin one explicitlyIMAGE_TAG defaults to the most recently created v* git tag (make tags lists the last five) —
an image is published per pushed tag, so that is the newest deployable one. Sorted by creation date
rather than by version, because git's version sort ranks a prerelease against its release
unpredictably. The target warns when that tag is unknown to your clone (git fetch --tags) or when
HEAD has commits the image does not contain, and refuses to deploy with no tag at all. make image
labels a local build from git describe instead (BUILD_TAG), so a dirty working tree can never
masquerade as a release.
The scripts can also be called directly:
./deploy-prod.sh v1.1.0 # release hutbot, environment default
./deploy-prod.sh v1.1.0 diff # preview first
./deploy-dev.sh v1.1.0 # release hutbot-dev, environment devBoth scripts check that the Secret exists and carries both Slack tokens, and stop before Helm
runs if not — under strategy: Recreate the old pod is gone before a broken new one would start.
They also clear a leftover spec.strategy.rollingUpdate block on an existing Deployment. A
Deployment first created with the default RollingUpdate strategy carries one the API server filled
in; server-side apply merges type: Recreate on top but leaves that block alone, and the API server
then refuses the object outright:
spec.strategy.rollingUpdate: Forbidden: may not be specified when strategy `type` is 'Recreate'
The equivalent by hand, if you ever hit it outside the scripts — idempotent, and it restarts nothing, since strategy is no part of the pod template:
kubectl -n mw-internal patch deployment hutbot --type=merge \
-p '{"spec":{"strategy":{"type":"Recreate","rollingUpdate":null}}}'HUTBOT_EXISTING_SECRET picks a different Secret name. helmfile can also be run directly; no
variable in helmfile.yaml.gotmpl is required any more, so helmfile -e default diff works in a
shell that has never seen a token.
The Helmfile defines two environments so a second, independent instance can run alongside production in the same namespace:
| Environment | Release name | Purpose |
|---|---|---|
default |
hutbot |
Production instance |
dev |
hutbot-dev |
Dev instance (separate Slack app, own PVC/Secret) |
All chart resources are named after the Helm release, so the dev release gets its own Deployment,
PersistentVolumeClaim (hutbot-dev-pvc) and NetworkPolicy, and reads its own Secret
(mw-internal/hutbot-dev, synced rather than templated). The two instances share nothing.
Register a second Slack app for the dev bot — Hutbot_DEV Slack App — and put its credentials in the dev Vault path, with the same field names production uses:
vault kv put secrets-coabkube/production/hutbot-dev \
SLACK_APP_TOKEN='<dev app-level token>' \
SLACK_BOT_TOKEN='<dev bot token>'
make sync-secret-devThen deploy the dev environment:
./deploy-dev.sh v1.1.0Production is unaffected and keeps deploying with ./deploy-prod.sh <tag>, which is
helmfile -e default sync with the pre-flight checks.
Note: Point the dev instance at its own Opsgenie heartbeat, or leave
OPSGENIE_HEARTBEAT_NAMEempty in the dev secret (an empty optional field is left out of the Secret rather than rejected), otherwise the dev pod will ping the production heartbeat.
HUTBOT_BOT_NAME (default Hutbot) is the name the bot uses for itself in user-facing text, so the
dev instance introduces itself as Hutbot (DEV) in its help and news messages. The Helmfile sets it
per environment (botName), and it can be overridden with the environment variable.
The @mention examples in the help text are not affected by this: they have to be a working Slack
handle, so they are taken from the bot's own auth.test response at startup (@hutbot vs.
@hutbot_dev) rather than from HUTBOT_BOT_NAME.
The name is used everywhere the instance identifies itself:
| Surface | With HUTBOT_BOT_NAME='Hutbot (DEV)' |
|---|---|
| Help / news | Hi! I am Hutbot (DEV) … |
| Web UI | Page title, wordmark and messages (served via /api/meta) |
| OpsGenie alert | Tag Hutbot (DEV), detail bot: hutbot-dev |
The OpsGenie alias — the field OpsGenie deduplicates on — uses a slug of the name
(Hutbot → hutbot, Hutbot (DEV) → hutbot-dev) instead of the display name. The production
alias is therefore unchanged, and dev alerts can no longer deduplicate against production ones.
A Slack app cannot register a slash command that another app already owns, so the dev Slack app has
to use its own — e.g. /hutbot_dev. The bot listens on the command in HUTBOT_SLASH_COMMAND
(default /hutbot) and uses it in all help and error texts, so /hutbot_dev help prints
/hutbot_dev … examples. If the running instance does not know about the command, Slack Bolt logs:
Unhandled request ({'type': None, 'command': '/hutbot_dev'})
The Helmfile sets this per environment (slashCommand in the environments block: /hutbot for
default, /hutbot_dev for dev), and it can be overridden per invocation:
export HUTBOT_SLASH_COMMAND=/hutbot_test
helmfile -e dev syncThe command name must match exactly what is configured in the Slack app (the leading / is added
automatically if omitted).
Each environment pins a specific image tag in the environments block of helmfile.yaml.gotmpl
(imageTag) — no floating latest. Bump that value to roll out a new release, or override it per
invocation to test a build in dev before promoting it to production:
export IMAGE_TAG=v1.1.0
export IMAGE_REPOSITORY=ghcr.io/mittwald/hutbot # optional
helmfile -e dev syncThe target namespace defaults to mw-internal for both environments and can be overridden with
NAMESPACE.
Hutbot stores its channel configuration in a JSON file (bot.json) on a mounted volume. You can configure the persistence options in the Helm chart like this:
persistence:
enabled: true
accessModes:
- ReadWriteOnce
size: 1Gi
storageClass: "<your-storage-class>"
mountPath: "/data"When persistence is enabled (default: true), the chart will automatically set the HUTBOT_CONFIG_FILE environment variable so Hutbot reads and writes its config from the mounted volume (at <mountPath>/bot.json).
Additionally, Hutbot stores its employee list cache in a JSON file (employees.json) on the same mounted volume. The chart will set the HUTBOT_EMPLOYEE_CACHE_FILE environment variable so Hutbot reads and writes its employee cache from the mounted volume (at <mountPath>/employees.json).
Beside that cache the chart also points HUTBOT_EMPLOYEE_FALLBACK_FILE at
<mountPath>/employees-fallback.json. That file is optional and Hutbot never writes it: it holds
records for people the employee API does not return — someone whose record is flagged deleted,
or who is missing from it entirely — so that they still get a team instead of <unknown>. It has
the same shape as employees.json, a JSON array of employee records, and only four fields
matter:
[
{"ad_name": "rmantler", "fullname": "Rudi Mantler", "group": "Support", "is_deleted": false}
]The employee list wins every collision: a fallback entry is used only for an ad_name the API
has no live record for. Set is_deleted to true to retire an entry without removing it. Place
the file and restart, since the employee list is read when the user cache is cold:
kubectl -n mw-internal cp employees-fallback.json <hutbot-pod>:/data/employees-fallback.json
kubectl -n mw-internal rollout restart deploy/hutbotAccounts that are nobody — shared or functional Slack users — belong in EMPLOYEE_LIST_MAPPINGS
as user=- instead, which silences the "cannot be mapped" warning without inventing a record.
Note: The PersistentVolumeClaim created by this chart is annotated with
helm.sh/resource-policy: keep, so it will not be deleted when you runhelm uninstall. You can manually remove the PVC (and its underlying volume) by runningkubectl delete pvc <release-name>-pvc. Keep in mind that if your StorageClass has aDeletereclaimPolicy, the underlying storage will still be deleted by the provisioner; to prevent this, use a StorageClass withReclaimPolicy: Retain.
If you override values via environment variables in Helmfile, you can configure persistence like this:
export PERSISTENCE_ENABLED=true
export PERSISTENCE_SIZE=1Gi
export PERSISTENCE_STORAGE_CLASS=<your-storage-class>
export PERSISTENCE_MOUNT_PATH=/data
# Egress rules for INTERNAL destinations (see above); public destinations and DNS are allowed
# by the chart defaults.
export NETWORKPOLICY_RULES='443:192.168.0.15/32 80:10.0.0.0/24,10.0.1.0/24'
# To define host aliases for the pod (/etc/hosts entries), you can set a comma-separated list of <hostname>=<ip> entries:
export HOST_ALIASES='lb.mittwald.it=192.168.0.15'
# Calendar feed hosts that resolve to an internal address and are meant to be fetched anyway.
# Without an entry the bot refuses the fetch and logs the address it resolved to. Needs the
# matching NETWORKPOLICY_RULES entry (and HOST_ALIASES, if cluster DNS does not serve the zone).
export HUTBOT_CALENDAR_ALLOWED_HOSTS='outlook-bridge.prod.example.systems'
# Calendar tunables; unset means the bot's own defaults, 300 seconds, 90 days and a day.
export HUTBOT_CALENDAR_TTL=300
export HUTBOT_CALENDAR_LOOKBACK_DAYS=90
# How long an unreachable feed's last copy is still used past the TTL; `0` stops that.
export HUTBOT_CALENDAR_STALE_GRACE=86400
# Minutes between reads of the calendar bridge's listing; unset means 60, `0` reads it once at
# startup. The listing URL itself carries a token and lives in the Secret, not here.
export HUTBOT_CALENDAR_BRIDGE_REFRESH=60
./deploy-prod.sh v1.1.0Hutbot is a Python package (hutbot/). The former monolithic bot.py has been
split into cohesive modules; bot.py remains as a backward-compatible launcher:
hutbot/state.py— shared in-memory caches and runtime flagshutbot/models.py,hutbot/constants.py— data types and configuration defaultshutbot/textutil.py,hutbot/datetimefmt.py— logging/text helpers and date/time formattinghutbot/persistence.py— config and cache load/savehutbot/slackcache.py— Slack user/channel/usergroup lookupshutbot/templating.py,hutbot/opsgenie.py— reply templates and OpsGenie integrationhutbot/calendarfeed.py— the ICS calendar feed (fetch, parse, current/next event) and the instance's built-in calendars (parse, look up, resolve to a URL)hutbot/conditionutil.py— condition normalization and evaluationhutbot/messaging.py,hutbot/actions.py,hutbot/buttons.py— sending, the action engine, and interactive buttons/escalationhutbot/scheduling.py,hutbot/routing.py— scheduled replies / cron triggers and Slack event routinghutbot/commands/— slash-command parsing and handlershutbot/webui_backend.py— the web-UI bridge (the HTTP server lives inwebui.py)hutbot/__main__.py— the entry point
Run it locally with python -m hutbot. Existing python bot.py invocations remain supported.
Every line carries a timestamp and a level (INFO, WARN, ERROR, DEBUG), and lines from
the libraries (Bolt, slack_sdk, aiohttp) are given the same shape. WARN, ERROR and
DEBUG lines also name what the process was doing when they were written:
2026-09-02T12:20:10 ERROR [message 1756808401.1 in #alerts from B08BSKV9CMB]: Failed to fetch user `B08BSKV9CMB`: SlackApiError: ...
The part in brackets is the inbound event, timer or background task the work belongs to — a
message, a reaction, a slash command, a button press, a scheduled reply, a reply being restored
at startup, a cron config, the scheduler's own tick, the calendar bridge refresh. It is set
once by the handler or task that owns the work (the handlers in hutbot/routing.py, and each
background task for itself — see logutil.log_origin) and carried down through the whole call
chain, so a failure deep in a lookup can be traced back to what caused it without turning on a
channel's debug flag.
pip install -r requirements-dev.txt
pytestTests live in tests/ (one file per module) and run automatically in CI before
the Docker image is built. make check additionally runs shellcheck on the deploy and secret
scripts and helm lint on the chart; the chart and script tests in
tests/test_deployment_validation.py skip themselves where helm is not installed.