Skip to content

About

A Telegram bot that turns voice messages into structured diary entries in Notion.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

Noter

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.

How it works

  1. You send a voice message to the bot
  2. OpenAI gpt-transcribe transcribes the audio
  3. 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
  4. The bot shows a preview — you can edit the title, text, or tags before saving
  5. Optionally mark the entry as a highlight ⭐
  6. Press Save — the entry is appended to today's Notion page (or a new page is created)
  7. Every day at 21:00 the bot sends a GPT-generated summary of the day

Commands

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

Editing before saving

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.

Misheard words

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.

Which day an entry belongs to

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.

Your own words

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.

Highlights

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.

Tags

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.

A second opinion on an entry

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.

Keeping the conversation going

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.

Rules it writes down itself

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.

What it learns about you

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.

Reading it — and correcting it — in Notion

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.

Rebuilding it from a diary that came first

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.

Once a week it writes first

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.

Making it yours

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.

Daily summary

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.

Notion database setup

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

Installation

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.txt

To work on the bot, install the development tools too — ruff and pytest, the same versions CI runs:

pip install -r requirements-dev.txt

Configuration

Copy .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

Choosing the chat provider

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.

Connecting Notion integration to your database

  1. Open your database in Notion
  2. Click ... → Connections → select your integration

Development

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.

  1. Message @BotFather, send /newbot, and give it a name you will recognise in a chat list — Noter (dev).

  2. Put the token it returns in .env.dev:

    echo 'TELEGRAM_TOKEN=<the dev bot token>' > .env.dev

    .env.dev only needs the values that differ from .env; everything else falls through, because variables already in the environment win over the ones python-dotenv loads from .env. It is gitignored, like .env.

    Point NOTION_DATABASE_ID at a scratch database too, if you would rather not write test entries into your real journal.

  3. 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.

What happened to make dev

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.

Deployment

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

Configuring your side

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-vps

make deploy HOST=noter-vps works for a one-off. With HOST unset, make deploy stops with an explanation before it opens a connection.

Server-side setup

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/noter

It 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_keys

3. The wrapper. One copy, from the checkout:

install -m 755 -o root -g root /opt/noter/deploy/noter-deploy /usr/local/bin/noter-deploy

Root-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 noter

5. 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.env

systemd 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.

Deploying

make deploy

Locally 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=always otherwise hides a crash loop behind an active unit, and systemd's own restart counter is at N lines 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; the active check 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.

Rolling back

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.

First-time VPS setup

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 noter

2. 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.txt

3. 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.env

systemd 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 noter

5. 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.

The unit

[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.target

What 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.

Migrating an existing root install

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.

Useful commands

systemctl status noter        # check status
journalctl -u noter -f        # live logs
systemctl restart noter       # restart manually
systemd-analyze security noter

Continuous integration

Every 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 check

Ruff 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.

Project structure

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

Estimated costs

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

License

Released under the MIT License.

About

A Telegram bot that turns voice messages into structured diary entries in Notion.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages