aboutsummaryrefslogtreecommitdiffstats
path: root/tg_backup.py
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 /tg_backup.py
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>
Diffstat (limited to 'tg_backup.py')
-rw-r--r--tg_backup.py40
1 files changed, 38 insertions, 2 deletions
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'))