Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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]"

Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/offline-regression.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ workflow 会串行执行,避免延迟的定时任务与手动任务重叠签
修改 `.github/workflows/main.yml` 中的 cron 可以调整时间。GitHub Actions cron 使用
UTC,定时任务可能晚于配置时间启动。

运行记录保存在 `logs` 分支的 `checkin.log`。它会记录目标用户名和命令,但不能证明
目标 bot 已经接受或处理命令。
运行记录保存在 `logs` 分支的 `checkin.log`,失败批次也保留部分记录。日志只包含
计数和时间,不包含目标用户名或命令。`submitted` 仅表示发送调用返回,不证明签到
成功。目标级错误会继续下一目标;账号、限流及结果不明的传输错误会停止批次,不
自动重发。任何失败均保持非零退出。

## 本地检查

Expand Down
107 changes: 76 additions & 31 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
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"])
API_HASH = os.environ["API_HASH"]
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:
Expand Down Expand Up @@ -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()))
83 changes: 83 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -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")