A Telegram bot that turns voice messages into structured diary entries in Notion. Designed to work with the Notion Journal — see the official setup guide to create one.
- You send a voice message to the bot
- OpenAI
gpt-transcribetranscribes the audio - GPT-4o-mini punctuates the transcription and picks a title and tags. It is told not to rewrite: filler, repetition and unfinished sentences are kept, because the entry is meant to read back as what was actually said
- The bot shows a preview — you can edit the title, text, or tags before saving
- Optionally mark the entry as a highlight ⭐
- Press Save — the entry is appended to today's Notion page (or a new page is created)
- Every day at 21:00 the bot sends a GPT-generated summary of the day
| Command | Description |
|---|---|
/start |
Welcome message and quick overview |
/help |
Detailed usage instructions |
/cancel |
Throw away the draft being previewed |
/keywords |
Manage the words the transcriber should lean towards |
/rules |
Show the standing instructions the coach has written down |
/memory |
Rebuild the profile from everything already in the diary |
After the voice message is processed, the bot sends four messages in sequence:
- Title — short heading generated by GPT
- Text — cleaned up transcription
- Tags — auto-extracted +
Daily(always added) - Action buttons
[ ✎ Title ] [ ✎ Text ] [ ✎ Tags ]
[ 📅 Today ]
[ Mark as Highlight ⭐ ]
[ ✨ Formatted · show raw ]
[ ✓ Save ]
[ ✕ Cancel ]
✕ Cancel throws the draft away: the preview messages are deleted and nothing
reaches Notion. It is the same thing /cancel does, and it sits on its own row
under Save rather than beside it — discarding cannot be undone, the transcription
has already been paid for, and a misplaced thumb should not be able to do it.
Clicking an edit button removes the buttons and prompts you to send a new value. After you send it, the corresponding message updates in place and the buttons return.
Names, places and jargon are what a transcriber gets wrong, and it gets them
wrong the same way every time — Кэт comes back as Скет, Спур as Споре.
Tell it what to expect:
/keywords add Кэт, Спур, Паттайя
/keywords # show the list
/keywords remove Спур
/keywords clear
They are hints, not instructions: a word appears in a transcript only when it is
actually in the audio. The list is kept with the bot's own state, so it survives
a restart and a deploy, and it is edited from the chat rather than from the
server. TRANSCRIPTION_KEYWORDS in the environment seeds a fresh install and is
merged with whatever has been added since.
Current models take the list as keywords; whisper-1, which has no such
parameter, gets the same words folded into its free-text prompt.
A diary day does not end at midnight. A note dictated at half past one is about
the day just lived through, not the one that started ninety minutes ago, so
anything recorded before DIARY_DAY_START_HOUR (default 4) is filed under the
previous date. The date picker, the daily summary and the weekly report all use
the same boundary. Set it to 0 for plain calendar days.
The entry's day is decided when the draft is made, not when Save is pressed, so dictating at 23:58 and saving at 00:01 does not move the entry to the next day.
📅 in the preview opens a picker of the last seven diary days — for the evening when you write up something you forgot on Sunday. The chosen day is marked with a dot; ← Back closes the picker without changing anything. Anything older than a week is rare enough to be worth editing in Notion itself.
The formatter is allowed to fix recognition errors, punctuation and paragraphs, and forbidden to remove or rephrase anything. It does not always obey, and the two guards below catch the worst of it — but both of them are the bot deciding, and you are the one who knows what you said.
✨ Formatted · show raw swaps the text for the transcription exactly as it came out of the transcriber, and 🎙 Raw · show formatted swaps it back. The label names the text that is on screen, so you can tell the two apart without reading them. Whichever one is on screen when you press ✓ Save is what reaches Notion — there is no hidden preference and nothing to set.
Only the text moves. The title is invented by the model in either case, the tags are the words you named yourself, and the date was decided when the draft was made; none of the three is what the formatter was trusted with.
There is no button when there is nothing to swap to — a long entry is never formatted in the first place, so its text is already your own. And if you edit the text by hand, the edit is kept on the side you made it on: toggling away and back returns your edit, not the version it replaced.
Press Mark as Highlight ⭐ to mark the entry as a key moment of the week. The button toggles — press again to unmark. When saved, the entry heading in Notion gets a ⭐ prefix so it's easy to spot.
The Daily tag is always added automatically. Additional tags can be:
- Extracted from voice — mention them naturally: "went for a run today. Tags: sport, health"
- Edited manually — click ✎ Tags and send tags separated by commas:
sport, health, work
When appending to an existing page, tags from all entries are merged without duplicates.
Under ✓ Save the preview offers three buttons. Each one sends the entry to a reasoning-grade model and posts its answer as a new message:
| Button | What it does |
|---|---|
| 🔥 Разъёб | Blunt. Names the thing the entry is circling and not saying. |
| 🧭 Разбор | Calm and structural: what happened, what drove it, what repeats. |
| 🫂 Поддержка | For the day when being taken apart is not what is needed. |
The draft is never modified. This is an opinion, not an edit — a feature that can rewrite a diary entry is a feature that will eventually rewrite one by accident. A failed call costs one line in the chat and leaves the preview exactly as it was, so the entry can still be saved.
The buttons only appear when the chat provider in use has a key; see Choosing the chat provider.
Reply to the coach's message to continue. Replying by voice works too — the audio is transcribed and added to the conversation, and it is never turned into a diary entry.
Conversations are stored in a file, so a deploy in the middle of one does not lose it. They are bounded — roughly fifty conversations, forty messages each, nothing older than a fortnight — and a reply to one that has aged out is answered honestly rather than silently starting a new conversation with no context.
Tell the coach how you want it to answer — «не начинай с приветствия», «забудь правило 2» — and it records the rule itself, from inside its own reply. The rules are appended to the end of every later prompt with a header saying they outrank the persona, so on a conflict the rule wins. That is how the bot's behaviour is changed by talking to it instead of by editing a file.
/rules prints the current list, numbered with the same ids the model uses.
Every saved entry is also read a second time, in the background, against what the bot already knows about you — and what it learns is added to a profile that grows one entry at a time. Nothing is asked of you: there is no command, and the entry reaches Notion whether or not this succeeds.
What it keeps is the durable half: traits and habitual reactions, values and fears, patterns that repeat, the people who matter, work and money and big goals, body and routine, skills, and the phase of life you are in. Not what you ate, not one day's mood. A single episode is noise; the pattern behind it is not.
When an entry teaches it something, it says so under the saved preview:
🧠 Память обновлена
About you:
+ [12] Откладывает трудные разговоры, пока они не решаются сами.
− Не любит звонить по телефону.
A reworded fact shows as both lines. An entry that taught it nothing says nothing — which is most entries.
Under the note, two buttons per fact:
| Button | What it does |
|---|---|
| ✗ неверно | Forget that fact. It goes to the tombstones, not to nothing. |
| ✎ поправить | Reply with better wording; the fact keeps its id and its history. |
Both are addressed by id rather than by position, so a button pressed twice, or pressed on a fact that has since gone, says so and changes nothing.
A failed extraction is always a no-op: an empty answer, a truncated one, a timeout, a provider outage — the profile is left exactly as it was and the next entry offers the same facts again. The one thing that is never risked is a fact you corrected by hand: if the profile changed while an extraction was in flight, that extraction is dropped rather than written.
Both lists are mirrored onto two ordinary pages beside the diary database:
| Page | Holds |
|---|---|
| Memory — Author profile | What the bot has learned about you from your entries |
| Memory — Bot rules | The standing instructions it follows |
They are created the first time the coach runs, and each is a plain bulleted list. You can edit them, and an edit you make by hand outranks whatever the bot stored: reword a bullet, reorder them, add one, delete one, and the change is adopted before the next answer is written.
A reworded bullet stays the same fact. Identity comes from the block id Notion
gives every bullet, not from comparing text — so the fact keeps its number, the
date it was first learned and the entries that taught it, and /rules goes on
printing it under the id the model uses.
To reset a list, empty its page. Deleting every bullet clears the list; the facts go to the tombstones in the memory file rather than to nothing.
The file on the server stays the source of truth for availability and the page
for intent. If Notion is down, slow, or has been moved out from under the
integration, the bot answers from the file, says so in the log, and carries on:
a read that did not succeed is never mistaken for a page you emptied. Pages are
read at most once every few tens of seconds, so a conversation's follow-up turns
do not each cost a round trip — /rules always reads afresh.
The pages are put on whatever page the diary database sits on. If your database
sits at the very top of the workspace there is no such page, and
NOTION_MEMORY_PARENT_PAGE_ID names one instead.
The profile only grows from entries saved after this feature shipped, so it knows
nothing about whatever was already written. /memory walks all of it and folds it
in — the same per-entry extraction, oldest first, each call seeing what the calls
before it learned.
It asks for two things before it does anything. First, what this pass should be
looking for — what matters, what to keep, what to drop. Answer by text or by
voice; - means no focus. What you say there is read only as focus: it is
never saved as a diary entry and never stored as a fact. Then it echoes the focus
and the number of facts it currently holds, and waits for you to confirm. Nothing
runs before that: the pass costs real money per entry and rewrites the thing the
whole coach reads.
On confirm it copies the memory file to a labelled snapshot before the first call, and the last message tells you which one — a pass that went badly is one file move away from being undone.
It is built to be dull. One entry at a time with a pause between them, never
concurrent. A second /memory while one is running is refused rather than queued.
An entry that cannot be read or extracted is counted and skipped, and the facts
already accumulated are untouched. Failures back to back stop the pass instead of
burning one doomed call per remaining entry. And the profile is written after
every entry, so a restart in the middle costs the entry in flight and nothing
else.
Everything above is the coach reacting to something you did. Once a week it opens the conversation itself: it reads the week's entries and the profile, and sends one thing — a pattern it noticed, or a single question worth sitting with. Not a recap; the weekly report already does that.
It is a normal coach message, so you can just reply to it and carry on.
A week with nothing written in it gets silence rather than a generated observation about nothing, and no week is ever spoken about twice — the week it last spoke about is on disk, so a deploy in between changes nothing. When something goes wrong it says nothing at all and puts the reason in the log.
| Variable | Default | What it is |
|---|---|---|
COACH_WEEKLY_ENABLED |
1 |
0 switches the whole thing off |
COACH_WEEKLY_DAY |
sunday |
The day it goes out, by name |
COACH_WEEKLY_HOUR |
12 |
The hour, in TIMEZONE |
COACH_WEEKLY_MINUTE |
0 |
The minute |
The default is midday Sunday, well clear of the 21:00 recaps, so that the week's report and the week's question are not read as one message.
The personas committed to this repository are neutral defaults, written to be usable by someone who has just cloned it. The ones actually in use come from the environment, so they stay off GitHub:
| Variable | Replaces |
|---|---|
COACH_PROMPT_ROAST |
the 🔥 Разъёб persona |
COACH_PROMPT_BREAKDOWN |
the 🧭 Разбор persona |
COACH_PROMPT_SUPPORT |
the 🫂 Поддержка persona |
Each replaces its persona paragraph entirely. The shared rules about the shape of an answer — a few sentences, one paragraph, no markdown, no question at the end — and the protocol for editing the rules list are added whatever you write, so a custom persona only has to describe who is answering.
Leave one unset to keep the committed default. COACH_MODEL picks the model;
it wants a reasoning-grade one, and it is called at high effort.
Every day at 21:00 (in your timezone) the bot sends a GPT-generated summary of all diary entries recorded that day. If no entries were recorded, it sends a friendly reminder instead.
The bot works with the Notion Journal database described in the official guide. Make sure your database has the following properties:
| Property | Type | Notes |
|---|---|---|
Name |
Title | Page title. Format: 9 May | Entry 1, Entry 2 |
Created |
Date | Set per entry. Used to find today's page |
Tags |
Multi-select | Auto-populated. Daily is always added |
Needs Python 3.10 or newer — that is the floor the pinned dependencies set, and
the code annotates with dict | None.
git clone https://github.com/shataev/audio-noter-bot.git
cd audio-noter-bot
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtTo work on the bot, install the development tools too — ruff and pytest,
the same versions CI runs:
pip install -r requirements-dev.txtCopy .env.example to .env and fill in the values:
cp .env.example .env| Variable | Description |
|---|---|
TELEGRAM_TOKEN |
Bot token from @BotFather |
OPENAI_API_KEY |
OpenAI API key. Required whatever AI_PROVIDER is: transcription is always OpenAI |
NOTION_TOKEN |
Internal integration secret from notion.so/profile/integrations |
NOTION_DATABASE_ID |
ID from the database URL: notion.so/workspace/{ID}?v=... |
ALLOWED_USER_ID |
Your Telegram user ID — get it from @userinfobot |
TIMEZONE |
Your timezone, e.g. Asia/Bangkok, Europe/Moscow |
DIARY_DAY_START_HOUR |
When a diary day ends. Default 4; 0 for calendar days |
The entry formatter and the two recaps go through one chat client, and
AI_PROVIDER picks the API behind it: openai (the default) or anthropic.
Transcription is not part of that choice. There is no Anthropic equivalent of
the audio endpoint, so it stays on OpenAI whatever the setting says — which is
why OPENAI_API_KEY is required in every configuration, and
ANTHROPIC_API_KEY only when AI_PROVIDER=anthropic. Missing then, the bot
refuses to start and names both the variable and the provider that asked for it.
anthropic also needs the anthropic package, which requirements.txt
installs. It is imported only when that provider is selected, so a server still
running an older install keeps working on OpenAI rather than failing at startup.
Each role has its own model, so the mechanical work stays cheap and only the work that has to think costs anything. Leave a variable unset to get the default for the provider in use:
| Variable | OpenAI default | Anthropic default | Used for |
|---|---|---|---|
TRANSCRIPTION_MODEL |
gpt-transcribe |
gpt-transcribe |
Voice message → text, always OpenAI |
FORMATTER_MODEL |
gpt-6-astra |
claude-opus-5 |
Punctuation, paragraphs, title, tags |
SUMMARY_MODEL |
gpt-6-astra |
claude-opus-5 |
Daily summary and weekly report |
COACH_MODEL |
gpt-5 |
claude-opus-5 |
The coach, at high effort |
PROFILE_MODEL |
gpt-4o-mini |
claude-opus-5 |
Learning about you from a saved entry, at low effort |
The two recaps are the least frequent model call here — one at 21:00 every day and one on Sunday, some thirty-four a month between them, against one per entry for the formatter and one per entry for the profile — and the daily one is read every evening. That makes it the cheapest quality in the project to buy, which is why it is not on the cheap model despite having no correctness constraint of its own.
The formatter is the one mechanical-looking role that does not run on the cheap model. Its instruction is "punctuate this and change nothing else", and holding that on ten minutes of dictation is the whole job: a model that does not hold it summarises, and the words are gone — there is no copy of the transcription anywhere you can reach.
Two things guard that, and they are belt and braces rather than alternatives:
| Variable | Default | Used for |
|---|---|---|
FORMATTER_FULL_TEXT_LIMIT |
6000 |
Longest entry, in characters, that the model is asked to hand back |
Past that length the model is asked for a title and tags only, is told not to return the text, and your transcription is used exactly as it came out of the transcriber — unpunctuated, but whole. A model that was never asked to echo six thousand characters back cannot shorten them. Below the length, the reply is compared against the transcription on letters and digits alone, and one that lost more than a tenth of them is refused the same way: the entry keeps your own words and the preview says so in one line.
- Open your database in Notion
- Click
...→Connections→ select your integration
Telegram allows one poller per bot token, so running this bot locally against the production token means stopping production first. Don't: get a second bot. It takes a few minutes, and after that local work never touches the server.
-
Message @BotFather, send
/newbot, and give it a name you will recognise in a chat list —Noter (dev). -
Put the token it returns in
.env.dev:echo 'TELEGRAM_TOKEN=<the dev bot token>' > .env.dev
.env.devonly needs the values that differ from.env; everything else falls through, because variables already in the environment win over the onespython-dotenvloads from.env. It is gitignored, like.env.Point
NOTION_DATABASE_IDat a scratch database too, if you would rather not write test entries into your real journal. -
Run it:
make run
Start a chat with the dev bot and send it a voice message. The production bot keeps running the whole time.
make dev used to ssh into the server, stop the bot, and run a local one
against the same token — leaving production down for as long as you worked, and
longer if the command was interrupted or the machine slept. Nothing told you.
It is gone, and it could not survive anyway: the deploy key is a
forced-command key, and stopping the bot is not one of the two things it can
ask for. Both targets now print this and exit. Use make run.
The bot is deployed by one command over ssh, to a key that cannot do anything else. Three accounts are involved and each is deliberately weaker than the last:
| Account | Can | Cannot |
|---|---|---|
| you, on your machine | ask for a deploy or a rollback | reach a shell on the server |
noter-deploy, on the server |
write the code directory, restart the unit, read its journal | read the credentials |
noter, on the server |
run the bot | write the code, or be logged into |
HOST is the name of an entry in your ~/.ssh/config. The address, the login
and the key live there, which is why this repository — which is public — never
has to contain any of them.
Host noter-vps
HostName <your server>
User noter-deploy
IdentityFile ~/.ssh/id_ed25519_noter_deploy
IdentitiesOnly yes
Then point make at that alias. deploy.mk is gitignored and holds nothing
else — everything about the server itself is configured on the server:
cp deploy.mk.example deploy.mk
$EDITOR deploy.mk # HOST = noter-vpsmake deploy HOST=noter-vps works for a one-off. With HOST unset, make deploy stops with an explanation before it opens a connection.
Everything here is run as root on the server, once.
1. The deploy account. It owns the code and nothing else. No password, and nothing logs into it interactively — the key below is the only way in.
useradd --system --create-home --shell /bin/bash noter-deploy
chown -R noter-deploy:noter-deploy /opt/noter
chmod -R a+rX,go-w /opt/noterIt owns /opt/noter so that git pull and pip install need no privilege at
all. The alternative — leaving the directory root-owned and widening sudo to
cover git and pip — would be much worse: pip install runs code from the
package index, and sudo pip install runs it as root. Ownership solves the
same problem without handing anything root.
noter, the account the bot runs as, owns none of it and only reads.
2. The forced-command key. Generate a keypair for deploys on your machine
(ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_noter_deploy), then put the public
half in /home/noter-deploy/.ssh/authorized_keys — on one line:
restrict,command="/usr/local/bin/noter-deploy" ssh-ed25519 AAAA...your key... deploy
command= means that whatever the client asks for, the server runs
/usr/local/bin/noter-deploy and passes the request along in
SSH_ORIGINAL_COMMAND. restrict turns off port and agent forwarding, X11 and
pty allocation. The key cannot get a shell, and the wrapper accepts exactly two
requests: deploy <revision> and rollback <revision>.
install -d -m 700 -o noter-deploy -g noter-deploy /home/noter-deploy/.ssh
$EDITOR /home/noter-deploy/.ssh/authorized_keys
chown noter-deploy:noter-deploy /home/noter-deploy/.ssh/authorized_keys
chmod 600 /home/noter-deploy/.ssh/authorized_keys3. The wrapper. One copy, from the checkout:
install -m 755 -o root -g root /opt/noter/deploy/noter-deploy /usr/local/bin/noter-deployRoot-owned and not writable by noter-deploy, so the account the key logs into
cannot rewrite the only program that key can run.
4. The sudo rules. The deploy account reaches systemd through exactly these
three commands and nothing else. visudo -f /etc/sudoers.d/noter-deploy and
paste:
noter-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart noter
noter-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl is-active noter
noter-deploy ALL=(root) NOPASSWD: /usr/bin/journalctl -u noter *
These match what the wrapper calls, exactly — including is-active without
--quiet, because sudo matches the whole argument list and --quiet would not
be permitted by the rule above. NOPASSWD is not optional: a deploy has no
terminal, so a password prompt is a hang rather than a question.
Note what is not there: systemctl show -p NRestarts. Detecting a crash
loop matters — Restart=always means an "active" unit can be one that has died
five times in the last ten seconds — but it does not need a fourth rule.
systemd writes Scheduled restart job, restart counter is at N. into the unit's
own journal, which the third rule already allows reading, and the wrapper reads
it there. If the paths differ on your server, command -v systemctl journalctl
says what they are; they must match the rules byte for byte.
Check the rules before relying on them:
sudo -u noter-deploy sudo -n /usr/bin/systemctl is-active noter5. The credentials, which the deploy account must not be able to read. This
is the point of keeping them in /etc/noter/noter.env rather than in a .env
inside /opt/noter:
chown root:root /etc/noter/noter.env
chmod 600 /etc/noter/noter.envsystemd reads that file as root and hands the values to the service before
dropping to the noter user, so nothing else ever needs read access to it. A
.env in the code directory would instead be readable by whoever owns that
directory — which is now noter-deploy. That is the difference between a deploy
account that can restart the bot and one that can read the owner's diary and
four live API tokens. Keep it out of /opt/noter.
make deployLocally it refuses to do anything until the working tree is clean and make check passes, so uncommitted work cannot be half-deployed and code that does
not compile cannot leave the machine. Then it pushes, and asks the server to
deploy that exact revision:
ssh noter-vps deploy 4065fbd… # the full 40-character revision
The server pulls, checks that the revision it ended up on is the one that was
asked for — the client pushes the branch you are on and the server pulls the
branch it is on, and when those differ nothing you wrote gets deployed — then
installs requirements.txt, restarts the unit, waits, and proves the bot is
actually up rather than assuming it:
- the unit is still
active; - it has not restarted since the deploy —
Restart=alwaysotherwise hides a crash loop behind anactiveunit, and systemd's ownrestart counter is at Nlines in the journal are what give it away; - the journal since the restart contains
Bot started, the line the bot logs once it has registered its handlers and begun polling. Note that it is logged immediately before polling starts, so a failure inside polling itself — a revoked token, a second poller — still satisfies it; theactivecheck ten seconds later is what catches those.
If any of those fails it prints the last 40 journal lines and the revision that
was running before, and exits non-zero. Checking systemctl status immediately
after a restart proves nothing on its own: a bot that is about to die on a
missing import is reported active for the second or two it takes to get there.
Every deploy prints the revision it replaced, and a failed deploy repeats it:
make rollback REV=<git-revision>That resets the server's checkout to that revision, reinstalls dependencies,
restarts, and runs the same liveness check — a rollback that reports success
into a revision which also crash-loops would be a false reassurance at the worst
possible moment. The next make deploy fast-forwards back onto the branch as
usual.
Both verbs are the only two things the deploy key can ask for. Anything else —
an empty request, a malformed revision, systemctl stop noter — is refused
before it is used for anything.
The bot runs as its own unprivileged user. It holds four live API credentials and everything it transcribes is a diary, so it is given no more of the machine than it needs. Run these as root on the server.
1. Create the service user. System account, no home directory, no shell — nothing ever logs in as it.
useradd --system --no-create-home --shell /usr/sbin/nologin noter2. Check out the code. It ends up owned by noter-deploy, the account that
deploys — see Server-side setup, which creates it. What
matters is that noter does not own it: the service only ever reads the code,
and a bot that cannot rewrite what it is running is one less thing to worry
about.
git clone https://github.com/shataev/audio-noter-bot.git /opt/noter
cd /opt/noter
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt3. Put the credentials in /etc/noter/noter.env — not in a .env sitting
next to the code. Same KEY=value lines as .env.example, with no export
and no shell expansion.
install -d -m 755 /etc/noter
cp .env.example /etc/noter/noter.env
$EDITOR /etc/noter/noter.env # fill in all values
chown root:root /etc/noter/noter.env
chmod 600 /etc/noter/noter.envsystemd reads this file as root and hands the values to the service, so the
noter user never needs read access to it at all.
4. Install the unit from deploy/noter.service:
cp /opt/noter/deploy/noter.service /etc/systemd/system/noter.service
systemctl daemon-reload
systemctl enable --now noter5. Check it started.
systemctl status noter
journalctl -u noter -n 20 --no-pager # should contain "Bot started"6. Set up the deploy path — the account, the key, the wrapper and the sudo
rules — from Server-side setup. Until that is done the bot
runs, but make deploy has nothing to talk to.
[Unit]
Description=Noter Telegram Bot
Documentation=https://github.com/shataev/audio-noter-bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=noter
Group=noter
WorkingDirectory=/opt/noter
ExecStart=/opt/noter/.venv/bin/python bot.py
Restart=always
RestartSec=5
EnvironmentFile=/etc/noter/noter.env
StateDirectory=noter
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectProc=invisible
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
StateDirectoryMode=0700
[Install]
WantedBy=multi-user.targetWhat each line is for:
| Directive | Why |
|---|---|
User=noter, Group=noter |
The bot shells out to nothing and serves one person. Running it as root meant a bug in any of the three API clients was a bug with root's authority. |
EnvironmentFile=/etc/noter/noter.env |
Keeps the four live tokens out of the working directory, in one root-owned mode-600 file, instead of a .env that any process running as the service user could read. |
StateDirectory=noter |
Creates /var/lib/noter owned by the service user, exported as $STATE_DIRECTORY. The only writable path the bot has, and where any runtime state file belongs. |
StateDirectoryMode=0700 |
The default is 0755, which with systemd's default UMask=0022 would leave saved drafts world-readable on the server. A draft is a diary entry; noter is the only account that reads one. |
NoNewPrivileges=yes |
Nothing the bot runs ever needs to gain privileges, so setuid escalation is switched off for the whole process tree. |
PrivateTmp=yes |
Voice messages are written to a temporary file before transcription. A private /tmp keeps those recordings out of the shared one and clears them on stop. |
PrivateDevices=yes |
It touches no hardware; hides physical devices and blocks creating device nodes. |
ProtectSystem=strict |
The whole filesystem read-only except the state directory — /opt/noter included, so the service cannot modify its own code. |
ProtectHome=yes |
/home, /root and /run/user are hidden. Nothing the bot needs lives there. |
ProtectProc=invisible |
Other users' processes disappear from /proc. |
ProtectKernelTunables=yes |
It never writes to /proc/sys or /sys. |
ProtectKernelModules=yes |
It never loads a kernel module. |
ProtectControlGroups=yes |
It never edits cgroups. |
ProtectClock=yes |
It reads the clock for timezone handling and scheduled jobs; it never sets it. |
RestrictSUIDSGID=yes |
It never creates setuid or setgid files. |
RestrictNamespaces=yes |
It never creates namespaces of its own. |
LockPersonality=yes |
It never changes its execution domain. |
RestrictAddressFamilies=… |
HTTPS to Telegram, OpenAI and Notion, plus the local sockets glibc and journald need — nothing else. |
SystemCallFilter=@system-service |
systemd's baseline set for ordinary services. If the bot ever fails to start with an unexplained EPERM, this is the first line to remove. |
MemoryDenyWriteExecute is deliberately absent: it breaks JIT compilation and
some native extensions, and Python is exactly the runtime that trips over it.
systemd-analyze security noter scores the result, and
systemd-analyze verify /etc/systemd/system/noter.service checks it parses.
The transcriptions in the journal are personal data and .env holds four live
tokens, so file ownership is the point of this, not a formality. In order, as
root:
# 1. Create the service user.
useradd --system --no-create-home --shell /usr/sbin/nologin noter
# 2. Move the credentials out of the working directory.
install -d -m 755 /etc/noter
mv /opt/noter/.env /etc/noter/noter.env
chown root:root /etc/noter/noter.env
chmod 600 /etc/noter/noter.env
# 3. Make sure nothing is left behind: config.py calls load_dotenv(), which
# reads .env from the working directory if one is there.
ls -la /opt/noter/.env # must be "No such file or directory"
# 4. Fix ownership of the code: the deploy account owns it, the service user
# only reads it. This is also what lets `git pull` and `pip install` run
# without sudo. /opt/noter is root-owned today, so this is the step that
# makes deploying as noter-deploy possible at all.
chown -R noter-deploy:noter-deploy /opt/noter
chmod -R a+rX,go-w /opt/noter
# 5. Install the new unit and reload.
cp /opt/noter/deploy/noter.service /etc/systemd/system/noter.service
systemctl daemon-reload
# 6. Restart, and confirm it came back.
systemctl restart noter
sleep 10
systemctl is-active noter
journalctl -u noter -n 20 --no-pager # should contain "Bot started"If it does not come back, journalctl -u noter -n 50 says why; the usual cause
is a value that did not survive the move into noter.env. Putting User=root
back and reloading returns you to where you started.
Step 2 is the one that matters most and the one that is easy to half-finish.
Before it, .env sits in /opt/noter — which step 4 hands to noter-deploy,
so a deploy account would be able to read the Telegram, OpenAI and Notion tokens
and every transcription they can fetch. After it, systemd reads the values as
root and passes them in, and no account on the machine except root can read the
file. Do not reorder these two, and do not skip step 3.
Anything the bot wrote under /opt/noter while it ran as root — there is
nothing today, but check — needs chown noter:noter and moving to
/var/lib/noter, because ProtectSystem=strict makes /opt/noter read-only
to the service.
The .venv moves owner with the rest of /opt/noter in step 4. If it was built
by root and any file in it is mode 600, chmod -R a+rX,go-w above fixes the
read bits; noter needs to read and execute it, and noter-deploy needs to
write it when dependencies change.
systemctl status noter # check status
journalctl -u noter -f # live logs
systemctl restart noter # restart manually
systemd-analyze security noterEvery push, and every pull request against main, runs
.github/workflows/ci.yml: it byte-compiles the
project, runs ruff check and ruff format --check, and runs pytest.
The same checks run locally, and make deploy will not deploy without them:
pip install -r requirements-dev.txt
make checkRuff is currently scoped away from bot.py, config.py and services/ — see
the note in pyproject.toml. Byte-compiling and tests/test_imports.py still
cover those files.
tests/ is not scoped away: new test files have to satisfy line length 100,
select = ["E", "F", "I", "UP", "B"] and ruff format. Run make check before
opening a pull request and there are no surprises.
noter/
├── bot.py # Telegram bot entry point
├── config.py # Settings loaded from .env
├── services/
│ ├── whisper.py # Audio transcription via the OpenAI audio API
│ ├── formatter.py # Entry formatting via GPT-4o-mini
│ ├── notion.py # Notion API: create/update diary pages
│ ├── summary.py # Daily summary and weekly report generation
│ ├── ai.py # One chat interface, OpenAI or Anthropic behind it
│ ├── memory_sync.py # The memory, mirrored onto two Notion pages you can edit
│ └── coach/
│ ├── memory.py # Id-addressed lists of facts the model edits
│ ├── store.py # The memory file: atomic writes, tolerant loads
│ ├── prompts.py # The three personas, and the self-editing rules
│ ├── threads.py # Conversations, in a file that outlives a deploy
│ ├── conversation.py # One coach turn: ask, answer, update the rules
│ ├── profile.py # What a saved entry taught it about its author
│ ├── diary.py # An entry, as plain data on the coach's side
│ ├── rebuild.py # /memory: the whole diary, one entry at a time
│ └── weekly.py # The one message a week it sends unprompted
├── Makefile # Dev and deploy commands
├── requirements.txt
└── .env.example
Both transcription and GPT-4o-mini are very cheap for personal use:
| Service | Price | Cost per entry (~1 min voice) |
|---|---|---|
| Transcription | $0.006 / minute | ~$0.006 |
| GPT-4o-mini | $0.15 / 1M tokens | ~$0.00005 |
100 entries/month ≈ $0.60
Released under the MIT License.