From 44bfb4b621e53c3fcd290ef262e231ef6522ead8 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:20:16 +0800 Subject: [PATCH] fix: isolate target failures and preserve sanitized partial results Co-Authored-By: Codex --- .github/workflows/main.yml | 3 + .github/workflows/offline-regression.yml | 26 ++++++ README.md | 9 +- README_CN.md | 6 +- main.py | 107 ++++++++++++++++------- tests/test_main.py | 83 ++++++++++++++++++ 6 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/offline-regression.yml create mode 100644 tests/test_main.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8c290c3..a4739ca 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,6 +34,7 @@ jobs: pip install telethon - name: Run check-in script + id: checkin env: API_ID: ${{ secrets.API_ID }} API_HASH: ${{ secrets.API_HASH }} @@ -42,7 +43,9 @@ jobs: run: python main.py - name: Push log to logs branch + if: ${{ !cancelled() && steps.checkin.outcome != 'skipped' }} run: | + test -f checkin.log || exit 0 git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" diff --git a/.github/workflows/offline-regression.yml b/.github/workflows/offline-regression.yml new file mode 100644 index 0000000..ab379d9 --- /dev/null +++ b/.github/workflows/offline-regression.yml @@ -0,0 +1,26 @@ +name: Offline regression + +on: + push: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + - name: Install existing test dependency + run: python -m pip install telethon + - name: Mock Telegram regression tests + run: python -m unittest discover -s tests diff --git a/README.md b/README.md index 0c78783..91a0d0e 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,12 @@ configuration changes. Edit the cron expression in `.github/workflows/main.yml` to change the schedule. GitHub Actions cron uses UTC and scheduled jobs may start late. -Run records are stored in `checkin.log` on the `logs` branch. They list target -usernames and commands but do not prove that a destination bot accepted or -processed a command. +Run records are stored in `checkin.log` on the `logs` branch, including partial +runs that exit with an error. They contain only counts and a timestamp, not +target usernames or commands. `submitted` means the send call returned, not +that a destination bot accepted a check-in. Target-specific failures allow the +next target to run; account/rate-limit failures and unknown transport outcomes +stop the batch without automatic resending. Any failure keeps a nonzero exit. ## Local validation diff --git a/README_CN.md b/README_CN.md index ec87ec1..336541a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -86,8 +86,10 @@ workflow 会串行执行,避免延迟的定时任务与手动任务重叠签 修改 `.github/workflows/main.yml` 中的 cron 可以调整时间。GitHub Actions cron 使用 UTC,定时任务可能晚于配置时间启动。 -运行记录保存在 `logs` 分支的 `checkin.log`。它会记录目标用户名和命令,但不能证明 -目标 bot 已经接受或处理命令。 +运行记录保存在 `logs` 分支的 `checkin.log`,失败批次也保留部分记录。日志只包含 +计数和时间,不包含目标用户名或命令。`submitted` 仅表示发送调用返回,不证明签到 +成功。目标级错误会继续下一目标;账号、限流及结果不明的传输错误会停止批次,不 +自动重发。任何失败均保持非零退出。 ## 本地检查 diff --git a/main.py b/main.py index 6698732..db9b6e1 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,19 @@ import os import asyncio import random +import json from datetime import datetime, timezone, timedelta from telethon import TelegramClient from telethon.sessions import StringSession +from telethon.errors import ( + ChatWriteForbiddenError, + InputUserDeactivatedError, + PeerIdInvalidError, + RPCError, + UserIsBlockedError, + UsernameInvalidError, + UsernameNotOccupiedError, +) # Load credentials and bot configuration from environment API_ID = int(os.environ["API_ID"]) @@ -11,6 +21,11 @@ SESSION_STRING = os.environ["SESSION_STRING"] # Combined mapping of bot usernames and commands, e.g. "@bot1:/qd,@bot2:sign,@bot3" BOT_CONFIG_RAW = os.environ.get("BOT_CONFIG", "").strip() +TARGET_ERRORS = ( + ValueError, UsernameInvalidError, UsernameNotOccupiedError, + ChatWriteForbiddenError, InputUserDeactivatedError, PeerIdInvalidError, + UserIsBlockedError, +) def _resolve_command(cmd: str) -> str: @@ -50,40 +65,70 @@ def get_bot_command_list() -> list[tuple[str, str]]: return result -async def main(): +async def main() -> int: # Random startup delay to mimic human behavior and reduce spam risk delay_seconds = random.randint(60, 300) - await asyncio.sleep(delay_seconds) - - print("Connecting to Telegram...") - client = TelegramClient(StringSession(SESSION_STRING), API_ID, API_HASH) - await client.start() - print("Logged in.\n") - bot_command_list = get_bot_command_list() - success_bots = [] - - for bot_username, sign_cmd in bot_command_list: - print(f"Sending sign command '{sign_cmd}' to {bot_username}...") - await client.send_message(bot_username, sign_cmd) - success_bots.append(f"{bot_username}({sign_cmd})") - - sleep_time = random.randint(2, 5) - await asyncio.sleep(sleep_time) - - print("\nAll sign commands sent.") - await client.disconnect() - - tz = timezone(timedelta(hours=8)) - current_time = datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S") - log_msg = ( - f"[{current_time}] Sent sign commands to {', '.join(success_bots)} " - f"(start delay: {delay_seconds}s).\n" - ) - with open("checkin.log", "a", encoding="utf-8") as f: - f.write(log_msg) - print("Log written to checkin.log") + record = { + "kind": "command_submission", "targets": len(bot_command_list), + "attempted": 0, "submitted": 0, "target_failed": 0, + "global_failed": 0, "send_unconfirmed": 0, + "stopped": False, "disconnect_failed": False, + } + client = None + log_failed = False + try: + await asyncio.sleep(delay_seconds) + client = TelegramClient(StringSession(SESSION_STRING), API_ID, API_HASH) + await client.start() + for bot_username, sign_cmd in bot_command_list: + record["attempted"] += 1 + try: + await client.send_message(bot_username, sign_cmd) + except TARGET_ERRORS: + record["target_failed"] += 1 + except RPCError: + # Authentication, account limits and other global RPC failures + # must not be retried against every remaining destination. + record["global_failed"] += 1 + record["stopped"] = True + break + except asyncio.CancelledError: + record["send_unconfirmed"] += 1 + record["stopped"] = True + raise + except Exception: + # A transport failure may happen after Telegram accepted input. + record["send_unconfirmed"] += 1 + record["stopped"] = True + break + else: + record["submitted"] += 1 + await asyncio.sleep(random.randint(2, 5)) + except asyncio.CancelledError: + record["stopped"] = True + raise + except Exception: + record["global_failed"] += 1 + record["stopped"] = True + finally: + if client is not None: + try: + await client.disconnect() + except Exception: + record["disconnect_failed"] = True + record["not_attempted"] = record["targets"] - record["attempted"] + record["time"] = datetime.now(timezone(timedelta(hours=8))).isoformat() + log_msg = json.dumps(record, sort_keys=True) + "\n" + print(log_msg, end="") + try: + with open("checkin.log", "a", encoding="utf-8") as f: + f.write(log_msg) + except OSError: + log_failed = True + print("Error: submission summary could not be saved.") + return int(log_failed or record["target_failed"] > 0 or record["stopped"] or record["disconnect_failed"]) if __name__ == "__main__": - asyncio.run(main()) + raise SystemExit(asyncio.run(main())) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..5cfac95 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,83 @@ +import contextlib +import io +import json +import os +import unittest +from unittest.mock import AsyncMock, mock_open, patch + +from telethon.errors import FloodWaitError, UsernameInvalidError + +with patch.dict(os.environ, { + "API_ID": "1", "API_HASH": "synthetic-placeholder", + "SESSION_STRING": "synthetic-placeholder", +}): + import main + + +class MainTests(unittest.IsolatedAsyncioTestCase): + async def run_script(self, *, send_effect=None, start_effect=None, disconnect_effect=None): + client = AsyncMock() + client.send_message.side_effect = send_effect + client.start.side_effect = start_effect + client.disconnect.side_effect = disconnect_effect + log = mock_open() + stdout = io.StringIO() + with ( + patch.object(main, "TelegramClient", return_value=client), + patch.object(main, "StringSession", return_value=object()), + patch.object(main, "BOT_CONFIG_RAW", "@synthetic_first:/qd,@synthetic_second:/qd,@synthetic_third:/qd"), + patch.object(main.asyncio, "sleep", new=AsyncMock()), + patch("main.open", log, create=True), + contextlib.redirect_stdout(stdout), + ): + code = await main.main() + record = json.loads(log().write.call_args.args[0]) + self.assertNotIn("@synthetic_", stdout.getvalue()) + self.assertNotIn("SYNTHETIC_PRIVATE_DETAIL", stdout.getvalue()) + self.assertNotIn("SYNTHETIC_PRIVATE_DETAIL", json.dumps(record)) + client.disconnect.assert_awaited_once() + return code, record, client + + async def test_target_failure_continues_and_records_partial_counts(self): + code, record, client = await self.run_script(send_effect=[None, UsernameInvalidError(None), None]) + self.assertEqual(code, 1) + self.assertEqual(client.send_message.await_count, 3) + self.assertEqual(record["submitted"], 2) + self.assertEqual(record["target_failed"], 1) + self.assertEqual(record["not_attempted"], 0) + + async def test_global_rate_limit_stops_without_retry(self): + code, record, client = await self.run_script(send_effect=[None, FloodWaitError(None, capture=60), None]) + self.assertEqual(code, 1) + self.assertEqual(client.send_message.await_count, 2) + self.assertEqual(record["submitted"], 1) + self.assertEqual(record["global_failed"], 1) + self.assertEqual(record["not_attempted"], 1) + + async def test_unknown_send_stops_and_never_logs_exception(self): + code, record, client = await self.run_script(send_effect=[None, RuntimeError("SYNTHETIC_PRIVATE_DETAIL"), None]) + self.assertEqual(code, 1) + self.assertEqual(client.send_message.await_count, 2) + self.assertEqual(record["send_unconfirmed"], 1) + self.assertEqual(record["not_attempted"], 1) + + async def test_start_failure_still_disconnects_and_records_unattempted(self): + code, record, client = await self.run_script(start_effect=RuntimeError("SYNTHETIC_PRIVATE_DETAIL")) + self.assertEqual(code, 1) + client.send_message.assert_not_awaited() + self.assertEqual(record["attempted"], 0) + self.assertEqual(record["not_attempted"], 3) + self.assertTrue(record["stopped"]) + + async def test_disconnect_failure_keeps_submission_counts_and_fails(self): + code, record, _ = await self.run_script(disconnect_effect=RuntimeError("SYNTHETIC_PRIVATE_DETAIL")) + self.assertEqual(code, 1) + self.assertEqual(record["submitted"], 3) + self.assertTrue(record["disconnect_failed"]) + + async def test_all_submissions_return_zero_without_claiming_checkin(self): + code, record, _ = await self.run_script() + self.assertEqual(code, 0) + self.assertEqual(record["attempted"], 3) + self.assertEqual(record["submitted"], 3) + self.assertEqual(record["kind"], "command_submission")