mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +01:00
envfile.py edits the file in place, line by line: only the KEY= line of a key that actually changed is replaced, so the paragraph explaining why GROUP_IDS is empty survives an edit made in a browser. A regenerated file would be correct and would still lose most of what that file is for. Temp-then-replace with the mode reapplied before the swap, because .env holds the account number and is edited from a tab that can be closed mid-save. envkeys.py is the policy, as data. Nothing here re-implements validation: load_config(candidate, use_dotenv=False) is what startup runs, so an edit this bot would refuse at boot is refused at the form instead, with the journal's own message. Read-only is wider than "the phone number". API_BASE_URL repoints every outgoing message and attachment fetch at the next restart, which is worse than changing the number; DATABASE_PATH orphans the history; PHOTO_PACK_DIR cannot be validated at all, because _photo_pack_dirs() reads os.environ directly rather than the mapping it is handed -- deliberately, since that is what keeps the suite away from a real album. GROUP_IDS and SELFTEST_GROUPS are editable but refused while no group listing has been read. parse_group_ids only checks syntax; startup also requires the group to be one the bot actually joined, so writing one blind with the container down turns a scoping mistake into a unit that will not start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
124 lines
4.4 KiB
Python
124 lines
4.4 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Rewriting ``.env`` without losing what it says about itself.
|
|
|
|
The file is edited in place, line by line: only the ``KEY=`` line of a key
|
|
that actually changed is replaced, so every comment in ``.env.sample``'s
|
|
descendant survives an edit made in a browser. A regenerated file would be
|
|
correct and would still lose the paragraph explaining why ``GROUP_IDS`` is
|
|
empty, which is most of what that file is.
|
|
|
|
Nothing here validates. ``admin_config`` builds the candidate mapping and
|
|
hands it to :func:`signal_bot.config_env.load_config` -- the same function
|
|
startup uses, so an edit this repository would refuse at boot is refused at
|
|
the form instead of eight hours later in ``systemctl --user --failed``.
|
|
|
|
Which keys may be edited at all is policy, and lives in ``envkeys``. This
|
|
module only knows how to read the file and put it back.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator, Mapping
|
|
from pathlib import Path
|
|
|
|
# .env is a secret: it holds the account's number and the identities allowed
|
|
# to add the bot to a group. Rewriting must not widen it.
|
|
FILE_MODE: Final = 0o600
|
|
|
|
_MARKER: Final = "# Added by the management page."
|
|
|
|
|
|
def parse(text: str) -> dict[str, str]:
|
|
"""Every ``KEY=value`` in a ``.env``, comments and blanks ignored.
|
|
|
|
Args:
|
|
text: The file's contents.
|
|
|
|
Returns:
|
|
The keys in file order, values stripped of surrounding whitespace.
|
|
"""
|
|
return {key: value for key, value, _ in _assignments(text)}
|
|
|
|
|
|
def _assignments(text: str) -> Iterator[tuple[str, str, int]]:
|
|
"""Yield ``(key, value, line index)`` for every assignment line."""
|
|
for index, line in enumerate(text.splitlines()):
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
continue
|
|
key, _, value = stripped.partition("=")
|
|
key = key.strip()
|
|
if key:
|
|
yield key, value.strip(), index
|
|
|
|
|
|
def candidate(text: str, changes: Mapping[str, str]) -> dict[str, str]:
|
|
"""The environment the bot would load if ``changes`` were written.
|
|
|
|
The process environment is included underneath because that is the order
|
|
``load_config`` sees in production: ``load_dotenv`` does not overwrite a
|
|
value systemd already exported, so a check that ignored it would validate
|
|
a file the running unit does not actually use.
|
|
|
|
Args:
|
|
text: The current ``.env`` contents.
|
|
changes: The edited keys and their new values.
|
|
|
|
Returns:
|
|
A mapping suitable for ``load_config(env, use_dotenv=False)``.
|
|
"""
|
|
merged = dict(parse(text))
|
|
merged.update(changes)
|
|
for key, value in os.environ.items():
|
|
if key not in changes:
|
|
merged.setdefault(key, value)
|
|
return merged
|
|
|
|
|
|
def render(text: str, changes: Mapping[str, str]) -> str:
|
|
"""The rewritten file: same comments, same order, new values.
|
|
|
|
A key already present is replaced where it stands. A key absent from the
|
|
file is appended under a marker rather than dropped into the middle,
|
|
because the surrounding comments explain their own key and not this one.
|
|
|
|
Args:
|
|
text: The current ``.env`` contents.
|
|
changes: The edited keys and their new values.
|
|
|
|
Returns:
|
|
The full new contents, newline-terminated.
|
|
"""
|
|
lines = text.splitlines()
|
|
seen: set[str] = set()
|
|
for key, _, index in _assignments(text):
|
|
if key in changes:
|
|
lines[index] = f"{key}={changes[key]}"
|
|
seen.add(key)
|
|
appended = [key for key in changes if key not in seen]
|
|
if appended:
|
|
lines.extend(["", _MARKER, *(f"{key}={changes[key]}" for key in appended)])
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def write(path: Path, changes: Mapping[str, str]) -> None:
|
|
"""Replace ``.env`` atomically, keeping its mode.
|
|
|
|
Temp-then-replace for the same reason the album's manifests use it: a
|
|
half-written ``.env`` is not a bad value, it is a bot that will not start,
|
|
and this file is edited from a browser tab that can be closed mid-save.
|
|
|
|
Args:
|
|
path: The ``.env`` to rewrite.
|
|
changes: The edited keys and their new values.
|
|
"""
|
|
text = path.read_text(encoding="utf-8")
|
|
temporary = path.with_name(path.name + ".tmp")
|
|
temporary.write_text(render(text, changes), encoding="utf-8")
|
|
temporary.chmod(FILE_MODE)
|
|
temporary.replace(path)
|