aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-31 09:00:21 +0200
committerDanilo M. <danix@danix.xyz>2026-07-31 09:00:21 +0200
commit2a52cb81744a55251dfd05cc1b2c4ee8dd311ebe (patch)
treefa6e795cef14eb0df14950ed934f3a266cd108bb
parentbc909d9886e0c679fec1589734199de8873f9469 (diff)
downloadtg_backup-2a52cb81744a55251dfd05cc1b2c4ee8dd311ebe.tar.gz
tg_backup-2a52cb81744a55251dfd05cc1b2c4ee8dd311ebe.zip
feat: refuse to back up a different chat into an existing archive dir
last_id is a high-water mark for one specific chat. Applied as min_id to a different one it silently skips that chat's history and the run still reports success. Compare the requested target against the one saved in state.json and exit before any network work when they differ. The comparison is textual, ignoring case and a leading @; resolving a username to its numeric ID would need a logged-in client, so switching between the two forms of the same chat also refuses. --force-target overrides and keeps the existing resume position. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md2
-rw-r--r--README.md5
-rw-r--r--tg_backup.py40
3 files changed, 43 insertions, 4 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index c0af417..e0fb0e1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,7 +41,7 @@ Resume/dedup is also file-based: media is named `{message.id}_{sanitized_name}`
`--archive-dir` holds media files, `state.json`, and `failures.json`, and is created if missing (`parents=True`). It is per-chat: one dir per backed-up chat.
-`state.json` also stores the `target` the dir was last backed up with, so subsequent runs need only `--archive-dir`. An explicit `--target` still wins; it is only required for a dir with no saved target (a first run, or one created before this was added). Nothing guards against pointing an existing dir at a *different* chat: `last_id` from the old chat would be applied as `min_id` to the new one and skip its history.
+`state.json` also stores the `target` the dir was last backed up with, so subsequent runs need only `--archive-dir`. An explicit `--target` still wins; it is only required for a dir with no saved target (a first run, or one created before this was added). Pointing an existing dir at a *different* chat is refused before any network work (`same_target` in `run_backup`), because `last_id` from the old chat would be applied as `min_id` to the new one and skip its history while reporting success. The comparison is textual, ignoring case and a leading `@`; it cannot tell that `@foo` and `-100…` are the same chat, since resolving that needs a logged-in client, so that case refuses too. `--force-target` overrides and keeps the existing resume position.
`failures.json` is append-only JSONL, one `{"id", "file", "error"}` object per line, written only when a download exhausts `MAX_RETRIES`. JSONL rather than a JSON array so appending is a single write with no read-modify-write: a crash costs the last line, not the file. Read it with `jq -s`. Nothing reads or prunes it, and a message failing on two runs is logged twice.
diff --git a/README.md b/README.md
index ee917fd..29c7521 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,9 @@ Update it later with just the directory:
tg_backup.py --archive-dir ~/backups/examplegroup
```
-The target is recorded in the archive directory on the first run, so repeat runs do not need it. Passing `--target` again still overrides what was saved. Point a directory at a different chat and the resume position carries over, which will skip that chat's history; use a fresh directory instead.
+The target is recorded in the archive directory on the first run, so repeat runs do not need it. Passing the same `--target` again is fine; the `@` and letter case do not have to match.
+
+Passing a *different* one is refused, because the directory's resume position belongs to the old chat and would skip the new chat's history. Use one directory per chat. If a chat genuinely changed handle or ID, `--force-target` accepts the new target and keeps the resume position. Switching between a chat's `@username` and its numeric ID also trips this, as the two cannot be compared without resolving them first.
### Options
@@ -75,6 +77,7 @@ The target is recorded in the archive directory on the first run, so repeat runs
| `--target` | first run only | Chat to back up: `@username`, numeric ID, t.me link, or `me`. Reused from the archive directory afterwards |
| `--archive-dir` | `./tg_archive` | Where media and resume state are written; created if missing |
| `--list-chats` | | Print your dialogs with IDs and usernames, then exit |
+| `--force-target` | | Accept a `--target` that differs from the one saved in the archive directory |
| `--self-check` | | Run internal assertions and exit. No network, no login |
Use one archive directory per chat. Prefer absolute paths: the default is relative to the current directory, so running from elsewhere silently starts a second archive.
diff --git a/tg_backup.py b/tg_backup.py
index 382a756..779d76e 100644
--- a/tg_backup.py
+++ b/tg_backup.py
@@ -144,6 +144,15 @@ def parse_target(target):
return int(t)
return t
+def same_target(a, b):
+ # Only catches targets that are textually the same chat. "@foo" and "foo"
+ # match; "@foo" and its numeric ID do not, since resolving them needs a
+ # logged-in client and the check runs before that.
+ if a is None or b is None:
+ return True # nothing saved to contradict
+ norm = lambda t: str(t).strip().lstrip('@').lower()
+ return norm(a) == norm(b)
+
def make_client(archive_dir):
# One session in CONFIG_DIR, shared by every archive dir: log in once, back
# up any number of chats.
@@ -189,7 +198,19 @@ async def list_chats(archive_dir):
await client.disconnect()
print("\nBack up with: --target=<ID> (the = matters for negative IDs)")
-async def run_backup(target, archive_dir):
+async def run_backup(target, archive_dir, force_target=False):
+ # last_id is a high-water mark for one specific chat. Applied as min_id to a
+ # different one it silently skips that chat's history, and the run reports
+ # success. Refuse before touching the network.
+ saved = load_state(archive_dir).get('target')
+ if not force_target and not same_target(saved, target):
+ sys.exit(f"Archive dir {archive_dir} was last backed up with target "
+ f"'{saved}', not '{target}'.\n"
+ "Its saved resume position belongs to that chat and would skip "
+ "this one's history. Use a separate archive dir per chat, or "
+ "pass --force-target to overwrite (only correct if the same "
+ "chat changed handle or ID).")
+
client = make_client(archive_dir)
await start_client(client)
@@ -251,6 +272,9 @@ def main():
help="Directory to store media and state.")
parser.add_argument("--list-chats", action="store_true",
help="List your dialogs with their IDs and exit.")
+ parser.add_argument("--force-target", action="store_true",
+ help="Back up --target into an archive dir saved with a "
+ "different one, keeping its resume position.")
args = parser.parse_args()
if args.list_chats:
@@ -259,7 +283,7 @@ def main():
# An archive dir already backed up once knows its own target.
target = args.target or load_state(args.archive_dir).get('target')
if target:
- asyncio.run(run_backup(target, args.archive_dir))
+ asyncio.run(run_backup(target, args.archive_dir, args.force_target))
else:
parser.error(f"--target is required the first time ({args.archive_dir}/state.json "
"has no saved target), or use --list-chats")
@@ -308,6 +332,18 @@ def _self_check():
assert load_state(archive).get('target') == '-1001234567890'
assert load_state(Path(tempfile.mkdtemp())).get('target') is None
+ # A dir keeps its chat. Handle case and the @ prefix are noise, a different
+ # chat is not. Nothing saved yet must never block a first run.
+ assert same_target('@examplegroup', 'examplegroup')
+ assert same_target('@ExampleGroup', ' @examplegroup ')
+ assert same_target('-1001234567890', '-1001234567890')
+ assert same_target(-1001234567890, '-1001234567890') # state may hold an int
+ assert same_target(None, '@examplegroup') # first run, nothing saved
+ assert same_target('@examplegroup', None) # resumed run, no --target given
+ assert not same_target('@examplegroup', '@othergroup')
+ assert not same_target('-1001234567890', '-1009876543210')
+ assert not same_target('@examplegroup', '-1001234567890') # unresolvable, refuse
+
# Failures append, one parseable record per line, and survive an exception
# object being passed straight in.
record_failure(archive, 11, '11_a.jpg', OSError('disk full'))